# API Overview Source: https://docs.splat-3d.com/api-reference/overview Base URL, authentication, response format, and error codes for the Splat3D API. # API Overview The Splat3D API lets you create 3D Gaussian Splat scenes programmatically. All endpoint documentation on this tab is auto-generated from the OpenAPI specification. ## Base URL ``` https://api.splat-3d.com ``` All endpoints are prefixed with `/v1/`. ## Authentication Every request requires a Bearer token in the `Authorization` header: ```bash theme={null} Authorization: Bearer s3d_your_key_here ``` See [Authentication](/authentication) for details on creating and managing API keys. ## Request format Request bodies must be JSON sent with `Content-Type: application/json` (`curl -d` sends form encoding unless you add that header), and create, `/process` and `/retrain` answer a body of any other type with `415 invalid_input`, before anything is created or charged. The body of `/process` and `/retrain` is optional: none, or an empty one, means `{}`. ## Response format ### Success responses All successful responses use the same envelope: ```json theme={null} { "data": { ... }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` List endpoints include pagination metadata: ```json theme={null} { "data": [ ... ], "meta": { "request_id": "...", "next_cursor": "MjAyNi0wNC0xM1QxMjowMDowMC4xMjM0NTYrMDA6MDA", "has_more": true, "count": 50 } } ``` ### Error responses Errors use a consistent structure: ```json theme={null} { "error": { "code": "invalid_input", "message": "iterations: Number must be less than or equal to 50000" }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` A request that fails the endpoint's schema also lists each problem in `details`, one `{field, message}` per field: ```json theme={null} { "error": { "code": "invalid_input", "message": "preset: Invalid enum value. Expected 'fast' | 'standard' | 'quality' | 'ultra' | 'custom', received 'nope'", "details": [ { "field": "preset", "message": "Invalid enum value. Expected 'fast' | 'standard' | 'quality' | 'ultra' | 'custom', received 'nope'" } ] }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` `meta` can hold more than `request_id`: a `429` names its limit there (see the error codes below). Treat a `code` you don't recognize by its HTTP status; new codes may be added. ## Request IDs Every response includes a unique `request_id` in the `meta` object and as an `X-Request-Id` header. Include this ID when contacting support about a specific request. ## Retrying scene processing `POST /v1/scenes/{id}/process` reserves at most one API launch for a scene. Repeat the same body to replay its saved result without another credit deduction, processing-quota increment, or GPU dispatch. You can supply an `Idempotency-Key` of 1–128 printable ASCII characters without spaces. If supplied, send the same key on every retry. Keys belong to the user, so changing API keys does not create another launch. A different body or key for an already reserved scene returns `409 conflict`. Reusing a key on another scene also returns `409`. Without a key, the scene ID and effective request body identify the launch. The `meta.request_id` remains unique to each HTTP request. A successful response means the launch was accepted. If Modal times out or returns an uncertain error, the reservation stays in place and retries replay acceptance. Poll `GET /v1/scenes/{id}` for the authoritative outcome; do not create another scene to retry an uncertain launch. The webhook and scheduled reconciler resolve the original job. A worker interruption before dispatch can leave a reservation pending until reconciliation fails and refunds it. Configuration, source-file, and signing failures before reservation do not charge credits. Definite dispatch rejection or capture-data storage failure records a failed result and refunds the captured charge once. Repeating that request returns the saved failure; it does not start a new attempt. Successful acceptance is also replayed after the scene finishes, so use GET to inspect its current status. These guarantees apply to the public `/process` route. Scene creation and `/retrain` are not retry-safe yet: `/retrain` creates a new scene on each request. Do not automatically retry those endpoints or submit the same scene simultaneously through the dashboard and API. ## Error codes | HTTP Status | Code | Description | | - | - | - | | `400` | `invalid_input` | A path, query, header or body value failed validation, a create, `PATCH`, `/process` or `/retrain` body has a key the endpoint doesn't take, or a JSON body doesn't parse. The message describes which field and why; a schema failure also lists each problem in `details` as `{field, message}`, e.g. `{"field": "presets", "message": "unrecognized key"}`. Includes any Pydantic 422 errors translated from the Modal backend (`translatePydantic422` in `api/src/lib/scenes.ts`). | | `400` | `source_missing` | `POST /v1/scenes/{id}/process` only: the scene's source file isn't in storage, e.g. the upload never finished. Nothing was charged. Upload it again (the upload URL from create lasts 24 hours; after that, create a new scene), then call `/process` again. | | `401` | `unauthorized` | Missing or invalid API key. | | `402` | `insufficient_credits` | You don't have enough credits to process this scene at the requested quality tier (`processScene` in `api/src/lib/scenes.ts`). | | `403` | `forbidden` | No endpoint sends it now. A scene another account owns answers `404`, as an unknown id does. | | `403` | `insufficient_scope` | The API key's [scopes](/authentication#scopes) don't cover the endpoint, e.g. a Read key on create, `PATCH`, `DELETE`, `/process`, `/retrain` or `/cancel`. The message names the scope needed. Nothing was read, changed or charged. | | `404` | `not_found` | The requested scene or resource doesn't exist, was deleted, or belongs to another account. These are not told apart. | | `409` | `conflict` | The scene is in a state that doesn't allow this action (e.g., already processing), or on `DELETE` it changed while being deleted. Try again. | | `409` | `retry_blocked` | `/retrain` only: the scene failed in a way a retry repeats (too few usable images, too few frames matched, or a crash in training on this file). No credits were charged. Upload a new capture, or for the last two, retrain at a different tier. A scene whose `sfm` or `training` values differ from its tier's defaults is never refused for the last two, since a retrain resets them. Values with no tier default, such as `training.strategy` or `sfm.options`, carry over to the retrain and don't count. | | `413` | `payload_too_large` | Uploaded file exceeds the 5 GB upload cap, the same on every plan. `/process` and `/retrain` refuse it before charging credits. | | `422` | `source_unusable` | `/process` and `/retrain`: the uploaded file won't make a usable scene. It is empty, a video under 30 seconds (read from its MP4 or MOV header; a length that can't be read launches), a 360° video at any length (a 2:1 frame, `video_aspect`, or spherical metadata, `video_projection`; not supported yet), a photo upload that isn't a zip, or a zip with fewer than 20 JPEG or PNG photos (HEIC, WebP and other formats are skipped; the message names them). A zip holding a COLMAP model needs 5, and a SplatKing video export (one `wide.mov`, under 5 photos) isn't counted. `details` holds the rule, e.g. `{"field": "photo_count", "value": 12, "allowed": {"min": 20}}` or `{"field": "video_seconds", "value": 12.4, "allowed": {"min": 30}}`. Nothing was charged. After `/process` the scene stays `uploading`: upload a usable file to the same upload URL (it lasts 24 hours from create) and call `/process` again, or create a new scene. `/retrain` creates no new version. | | `429` | `quota_exceeded` / `queue_full` / `rate_limited` | Either a monthly quota was hit (`quota_exceeded`), or, at `/process` and `/retrain`, as many of your scenes are processing as your plan allows (`queue_full`, with `Retry-After: 120`; nothing was charged), or you sent more requests this minute than the [rate limit](/authentication#rate-limits) allows (`rate_limited`, with `Retry-After: 60`; nothing was done). All put `limit` in `meta`. The `code` field disambiguates. | | `500` | `internal_error` | Something went wrong on our end. Retry or contact support with the request ID. | | `502` | `upstream_error` | `/process` and `/retrain`: the GPU backend refused the launch, or the capture data could not be stored. The charge was refunded. Repeating the same `/process` request returns this saved failure. | | `503` | `service_unavailable` | Temporary failure on our side, e.g. a database blip. It is not an answer about your key or scene. Reads, `DELETE` and `/process` carry `Retry-After`: repeat the same request after that many seconds. Create, `PATCH`, `/retrain` and `/cancel` carry none, because a repeated create or `/retrain` makes another scene: check `GET /v1/scenes` before sending them again. | ## Pagination List endpoints use cursor-based pagination: | Parameter | Type | Default | Description | | - | - | - | - | | `cursor` | `string` | — | `meta.next_cursor` from the previous page | | `limit` | `integer` | `50` | Items per page (1-100) | The cursor is opaque: pass it back unchanged. It is URL-safe, so it needs no encoding. A cursor the API can't read answers `400 invalid_input`. Scenes come newest first, and each list item has the shape `GET /v1/scenes/{id}` returns. ```bash theme={null} # First page curl https://api.splat-3d.com/v1/scenes?limit=20 \ -H "Authorization: Bearer $SPLAT3D_API_KEY" # Next page (next_cursor from the previous response) curl "https://api.splat-3d.com/v1/scenes?limit=20&cursor=$NEXT_CURSOR" \ -H "Authorization: Bearer $SPLAT3D_API_KEY" ``` ## Endpoints summary | Method | Endpoint | Description | | - | - | - | | `POST` | `/v1/scenes` | Create a scene and get a presigned upload URL. [Supported inputs](/guides/supported-inputs) lists the `content_type` for each input | | `GET` | `/v1/scenes` | List your scenes (paginated) | | `GET` | `/v1/scenes/{id}` | Get scene status, metrics and URLs | | `PATCH` | `/v1/scenes/{id}` | Update scene metadata (title, address, visibility) | | `DELETE` | `/v1/scenes/{id}` | Delete a scene. Every call answers `404` for it at once; a daily cleanup removes its files 24 to 48 hours later. An unfinished scene is cancelled first, under the cancel refund rules | | `POST` | `/v1/scenes/{id}/process` | Start the reconstruction pipeline | | `POST` | `/v1/scenes/{id}/retrain` | Process a scene's source again as a new scene, at the tier `quality_tier` or `preset` names. The source scene is left as it is | | `POST` | `/v1/scenes/{id}/cancel` | Cancel a running job | | `GET` | `/v1/scenes/{id}/download` | Download the 3D model: SPZ by default, otherwise SOG, then PLY. SOG or PLY on request, or SPZ (`404` if the scene has none) | | `GET` | `/v1/scenes/{id}/thumbnail` | Get the scene thumbnail image. A public scene's needs no key. A private scene's goes to its owner's key only: anyone else gets `404` | | `GET` | `/v1/usage` | Return usage and quota summary for the current billing period | Create answers `scene_id` and `upload_url`, `/process` answers `scene_id`, and `/retrain` answers the new scene as `scene_id` and the scene it came from as `source_scene_id`. Each also carries `id`, the same as `scene_id`. Create and `/process` still send the older `sceneId` and `uploadUrl`, which are deprecated: read the snake\_case keys. A `/retrain` refused before any charge keeps no new scene; after a `502` the new scene stays, failed and refunded. ## Processing lifecycle A scene moves through these statuses: ```mermaid theme={null} stateDiagram-v2 [*] --> uploading : scene created uploading --> processing : POST /process state "processing" as processing { extracting_frames --> estimating_poses estimating_poses --> training training --> exporting exporting --> compressing } processing --> complete processing --> failed : credits auto‑refunded processing --> cancelled : POST /cancel (refunded within 10 s of the job starting) complete --> [*] failed --> [*] cancelled --> [*] ``` While processing, `status` reflects the current pipeline stage. (Some older scenes report `running_sfm`, a legacy alias of `estimating_poses`, or one of the retired preview stages `preview_extracting`, `preview_generating` and `preview_compressing`.) A launch reports `extracting_frames` first, at 0% while it waits for a GPU, which can take minutes. At any point before `complete`, you can cancel processing. A cancel before the job starts, or within 10 seconds after, refunds the scene's credits. The job starts around when `/process` returns. A later cancel keeps the processing charge; only an AI Enhance charge reserved at upload comes back. The cancel response says what came back, in `refunded` and `refunded_credits`. If something goes wrong, the status becomes `failed` with an error message and the scene's credits are refunded automatically. The server fails and refunds a stuck job itself: one still queued after 90 minutes, or still processing after 150 (checked every 10 minutes). You will always reach a terminal status (`complete`, `failed`, or `cancelled`) without intervention. ### Tracking progress Poll `GET /v1/scenes/{id}` to track progress. The response is a flat `SceneDetail` object (`sceneDetailSchema` in `api/src/routes/schemas.ts`) regardless of state — SDKs can decode it with a single type. The list and `PATCH` return the same object. Key fields: | Field | Type | When populated | | - | - | - | | `status` | `string` | Always, the stored status, the same on every call (`uploading`, `extracting_frames`, `estimating_poses`, `training`, `exporting`, `compressing`, `complete`, `failed`, `cancelled`; older scenes may show `running_sfm` or a retired `preview_*` stage) | | `processing_stage` | `string \| null` | While processing — the stage the job reports live, which can be ahead of `status`. `null` otherwise, including once `complete` | | `processing_pct` | `number \| null` | While processing — 0-100 progress. `null` otherwise, including once `complete` | | `processing_error` | `string \| null` | On `failed` or `cancelled` | | `preset` | `string \| null` | From creation on: `fast`, `standard`, `quality`, `ultra` or `custom`. `null` when the scene's stored parameters name none | | `processing_started_at` | `string \| null` | Once processing is launched | | `processing_completed_at` | `string \| null` | On `complete` | | `training_time_seconds` | `number \| null` | Set on `complete`, kept if the scene later fails: the GPU training stage alone. The whole job runs from `processing_started_at` to `processing_completed_at` | | `spz_size_bytes`, `sog_size_bytes`, `ply_size_bytes` | `number \| null` | On `complete`, each when the scene has that file | | `num_gaussians` | `number \| null` | On `complete` | | `psnr` | `number \| null` | Set on `complete`, kept if the scene later fails: mean PSNR over the training images, so usually higher than `psnr_holdout` | | `psnr_holdout` | `number \| null` | On `complete` if images were held out (`output.holdout_ratio` above 0; every preset but `fast` by default) | | `ssim_holdout` | `number \| null` | On `complete` if images were held out, as `psnr_holdout` | | `viewer_url`, `download_url`, `format` | `string \| null` | On `complete`. `download_url` is `/download` without `format`, so it sends the file `format` names, and only to your API key. The response's `X-Splat-Format` header decides: when that file is missing from storage, the next one is sent | | `thumbnail_url` | `string \| null` | Once the scene has a thumbnail. A private scene's needs your API key | | `created_at`, `updated_at` | `string` | Always, UTC with milliseconds, e.g. `2026-04-13T12:00:00.000Z` | Poll at 5–10 second intervals and stop once `status` reaches `complete`, `failed`, or `cancelled`. A `503` while polling is transient: keep polling after its `Retry-After`. ## Webhooks Create a scene with a `webhook_url` (`https://` only) to get a `POST` when it finishes: [`scene.completed`](/api-reference/webhooks/scene-completed) or [`scene.failed`](/api-reference/webhooks/scene-failed), whose pages list every field. A cancel sends nothing, nor does a retrain's new version or a launch that `/process` answers with an error. Answer any 2xx; anything else is retried, 3 attempts in all. Redirects are followed, and a 301, 302 or 303 arrives as a `GET` without the body, so register the final URL. Add a `webhook_secret` and each request carries `X-Splat-Signature: t=,v1=`, where `v1` is the HMAC-SHA256 of `.` keyed with your secret. Check it against the body bytes as received: ```javascript theme={null} import { createHmac, timingSafeEqual } from "node:crypto"; const TOLERANCE_SECONDS = 300; // refuse replays of old requests function verifySignature(header, rawBody, secret) { if (!header) { return false; } const { t, v1 = "" } = Object.fromEntries(header.split(",").map((part) => part.split("="))); const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")); const received = Buffer.from(v1); const fresh = Math.abs(Date.now() / 1000 - Number(t)) <= TOLERANCE_SECONDS; // Compare bytes: timingSafeEqual throws on buffers of different lengths. return fresh && received.length === expected.length && timingSafeEqual(received, expected); } ``` # Cancel scene processing Source: https://docs.splat-3d.com/api-reference/scenes/cancel-scene-processing https://api.splat-3d.com/openapi.json post /v1/scenes/{id}/cancel Cancel an in-progress scene processing job. A cancel before the job starts, or within 10 seconds after, refunds the credits it charged. The job starts around when /process returns. A later cancel keeps the processing charge and refunds only an AI Enhance charge reserved at upload (dashboard launches; API launches reserve none). # Create a scene Source: https://docs.splat-3d.com/api-reference/scenes/create-a-scene https://api.splat-3d.com/openapi.json post /v1/scenes Create a new scene and get a presigned upload URL for the source file. # Delete a scene Source: https://docs.splat-3d.com/api-reference/scenes/delete-a-scene https://api.splat-3d.com/openapi.json delete /v1/scenes/{id} Delete a scene. Every call answers `404` for it at once; a daily cleanup removes its files 24 to 48 hours later. An unfinished scene is cancelled first, under the cancel refund rules; if the cancel fails, nothing is deleted. # Download 3D model file Source: https://docs.splat-3d.com/api-reference/scenes/download-3d-model-file https://api.splat-3d.com/openapi.json get /v1/scenes/{id}/download Download the processed 3D Gaussian Splat model file. # Get scene status Source: https://docs.splat-3d.com/api-reference/scenes/get-scene-status https://api.splat-3d.com/openapi.json get /v1/scenes/{id} Get detailed status and metadata for a scene. `status` is the stored status, as the list and PATCH return it. While the scene processes, `processing_stage` and `processing_pct` carry live progress. # Get scene thumbnail Source: https://docs.splat-3d.com/api-reference/scenes/get-scene-thumbnail https://api.splat-3d.com/openapi.json get /v1/scenes/{id}/thumbnail Get the PNG thumbnail for a scene. A public scene's needs no API key. A private scene's is served only to its owner's key: any other caller gets `404`, so whether a scene exists stays private. A deleted scene's is never served. # List scenes Source: https://docs.splat-3d.com/api-reference/scenes/list-scenes https://api.splat-3d.com/openapi.json get /v1/scenes List all scenes for the authenticated user, newest first, with cursor-based pagination. Each item has the shape `GET /v1/scenes/{id}` returns. # Retrain a scene Source: https://docs.splat-3d.com/api-reference/scenes/retrain-a-scene https://api.splat-3d.com/openapi.json post /v1/scenes/{id}/retrain Process a scene's source again as a new scene (a version), at the tier `quality_tier` or `preset` names, standard when neither is sent. `id` and `scene_id` name the new scene; the source scene is left as it is, running or not. Each request makes another version. A retrain refused before any charge keeps no new version; after a `502` the new version stays, failed and refunded. # Start processing a scene Source: https://docs.splat-3d.com/api-reference/scenes/start-processing-a-scene https://api.splat-3d.com/openapi.json post /v1/scenes/{id}/process Reserve one paid launch for an uploaded scene. Retry with the same body and optional Idempotency-Key to replay the saved result without another charge, processing-quota increment, or dispatch. An uncertain upstream response remains accepted until status reconciliation resolves it. # Update scene metadata Source: https://docs.splat-3d.com/api-reference/scenes/update-scene-metadata https://api.splat-3d.com/openapi.json patch /v1/scenes/{id} Update title, address, description, or visibility of a scene. # Get current usage and quotas Source: https://docs.splat-3d.com/api-reference/usage/get-current-usage-and-quotas https://api.splat-3d.com/openapi.json get /v1/usage Returns the authenticated user's usage for the current billing period and their plan limits. # Scene completed Source: https://docs.splat-3d.com/api-reference/webhooks/scene-completed https://api.splat-3d.com/openapi.json webhook scene.completed A scene finished processing. Sent as a `POST` to the scene's `webhook_url`, with `Content-Type: application/json` and `User-Agent: Splat-Webhook/1.0`. Answer any 2xx to acknowledge it. Any other status, or a network error, is retried: 3 attempts in all, 1 s then 2 s apart, with the same body and signature. Redirects are followed and the last response counts: a 301, 302 or 303 turns the `POST` into a `GET` without the body, so register the final URL. Only scenes created with a `webhook_url` send events; a retrain's new version sends none, a cancel sends none, and a launch that fails at `/process`, which answers with an error, sends none. A scene reported failed whose job then finishes sends `scene.completed` after `scene.failed`, so read `GET /v1/scenes/{id}` for the current state. # Scene failed Source: https://docs.splat-3d.com/api-reference/webhooks/scene-failed https://api.splat-3d.com/openapi.json webhook scene.failed A scene failed. Its credits are refunded. Sent as a `POST` to the scene's `webhook_url`, with `Content-Type: application/json` and `User-Agent: Splat-Webhook/1.0`. Answer any 2xx to acknowledge it. Any other status, or a network error, is retried: 3 attempts in all, 1 s then 2 s apart, with the same body and signature. Redirects are followed and the last response counts: a 301, 302 or 303 turns the `POST` into a `GET` without the body, so register the final URL. Only scenes created with a `webhook_url` send events; a retrain's new version sends none, a cancel sends none, and a launch that fails at `/process`, which answers with an error, sends none. A scene reported failed whose job then finishes sends `scene.completed` after `scene.failed`, so read `GET /v1/scenes/{id}` for the current state. # Authentication Source: https://docs.splat-3d.com/authentication Authenticate with the Splat3D API using Bearer token API keys. # Authentication All API requests require authentication via a Bearer token in the `Authorization` header. The one exception is the thumbnail of a public scene (`GET /v1/scenes/{id}/thumbnail`), which needs no key. ## API keys API keys use the `s3d_` prefix and are passed as Bearer tokens: ```bash theme={null} curl https://api.splat-3d.com/v1/scenes \ -H "Authorization: Bearer s3d_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" ``` ### Creating a key 1. Sign in to the [Splat3D dashboard](https://splat-3d.com/dashboard) 2. Go to **Settings > API Keys** 3. Click **Create Key** and give it a name 4. Copy the key immediately -- it's only shown once > [!WARNING] > > Your API key is shown exactly once when created. Store it securely. If you lose it, revoke the old key and create a new one. ### Scopes A key carries the scopes picked when it was created, All by default. The API checks them on every request (`requireScope` in `api/src/middleware/scope.ts`): | Pick | Scope | Allows | | - | - | - | | Read | `read:scenes` | Listing, reading and downloading your scenes | | Write | `write:scenes` | What Read allows, plus creating, editing, deleting, processing, retraining and cancelling scenes. Processing and retraining spend credits | | All | `*` | Every endpoint | `GET /v1/usage` takes any key. A key without the scope an endpoint needs gets `403 insufficient_scope`, before anything is read, changed or charged: ```json theme={null} { "error": { "code": "insufficient_scope", "message": "This API key lacks the write:scenes scope. Use a key created with Write or All access." }, "meta": { "request_id": "..." } } ``` ### Key format | Property | Value | | - | - | | Prefix | `s3d_` | | Length | 36 characters total (`s3d_` + 32 hex chars) | | Entropy | 128 bits (cryptographically random) | | Storage | SHA-256 hashed server-side | The web dashboard generates keys as `s3d_<32 hex chars>` (`POST` in `web/src/app/api/internal/keys/route.ts`) and stores only the SHA-256 hash. The API authenticator validates the `s3d_` prefix and looks up the SHA-256 hash; there is no length or entropy check at the edge beyond the prefix (`requireApiKeyAuth` in `api/src/middleware/auth.ts`). ### Key management API keys are managed exclusively from the web dashboard at [https://splat-3d.com/dashboard/settings](https://splat-3d.com/dashboard/settings). There is no public API for listing, creating, or revoking keys — this is by design to keep the auth perimeter simple. You can have up to **5 active API keys** per account (`MAX_ACTIVE_KEYS` in `web/src/app/api/internal/keys/route.ts`). ## Rate limits | Limit | Value | Enforced by | | - | - | - | | Requests per minute | 300 on every plan, per account (all its keys together), or per client IP for a request with no key or a key that matches no account | `REQUESTS_PER_MINUTE` in `api/src/middleware/rate-limit.ts` | | Create, `/process` and `/retrain` requests per minute | 30 on every plan, per account, counted in the 300 too | `LAUNCHES_PER_MINUTE` in `api/src/middleware/rate-limit.ts` | | Scenes processing at once | 2 on every plan, checked when `/process` starts a scene | `maxRunningScenes` in `packages/core/src/plans.ts` | | Scene creations per month | 10 on Explorer (free) · 100 on Creator (any account that has bought a credit pack) | `api/src/middleware/quota.ts` `PLAN_LIMITS` | | Scene processes per month | 10 on Explorer (free) · 100 on Creator (any account that has bought a credit pack) | `api/src/middleware/quota.ts` `PLAN_LIMITS` | | Max upload size | 5 GB on every plan (R2's single-PUT maximum) | `MAX_UPLOAD_BYTES` in `packages/core/src/uploads.ts`, checked by R2 HEAD at `POST /v1/scenes/{id}/process` | A request over a per-minute limit answers `429 rate_limited` with `Retry-After: 60`. It is refused before it reads or changes any scene, quota or credit balance: nothing is charged and no scene is made. Wait `Retry-After` seconds, then send the same request again. Polling at the recommended 5 to 10 seconds stays well under the limit: 5 scenes polled every 5 seconds is 60 requests a minute. ```json theme={null} { "error": { "code": "rate_limited", "message": "Rate limit reached: 300 requests a minute. Retry after 60 seconds." }, "meta": { "request_id": "...", "limit": 300 } } ``` Every `/v1` response carries `X-RateLimit-Limit`, the requests a minute its route allows: `30` on create, `/process` and `/retrain`, `300` on the rest. A `429 rate_limited` names the limit that refused it, there and in `meta.limit`. There is no `X-RateLimit-Remaining`: the counter behind the limits only says whether a request is over. > [!NOTE] > > The limits are counted per Cloudflare location and approximately: a fast > burst can pass one several times over before its first `429`, and a > refusal can clear in under a minute. Don't plan against the exact > numbers. They stop runaway loops and stolen keys; the monthly quotas and > credits below do the metering. When your scenes processing reach the cap, `POST /v1/scenes/{id}/process` (and `/retrain`) returns `429 queue_full` with `Retry-After: 120`. Nothing is charged and the scene stays `uploading`: call `/process` again once one finishes. A refused `/retrain` keeps no new version, unless the response carries no `Retry-After`: then check `GET /v1/scenes` before sending it again. Creating and uploading a scene is never refused for this. ```json theme={null} { "error": { "code": "queue_full", "message": "You have 2 scenes processing, and your plan allows 2 at once. Try again when one finishes." }, "meta": { "request_id": "...", "limit": 2, "current": 2 } } ``` Deleting a scene that is still processing cancels it first, under the cancel refund rules. A create counts against the scene-creation quota only once it makes a scene, so a refused create counts nothing. A retrain whose launch is refused before any charge (for example `402`, `413`, `422` or `429`) keeps no new scene, so it counts nothing. One whose launch is charged and then fails (`502`) keeps its new scene, which counts. When you hit a monthly quota, the API returns `429 quota_exceeded`: ```json theme={null} { "error": { "code": "quota_exceeded", "message": "Monthly scene processing limit reached." }, "meta": { "request_id": "...", "period": "2026-05", "limit": 10, "field": "scenes_processed" } } ``` ## Error responses All authentication errors follow the standard error envelope: | Status | Code | Meaning | | - | - | - | | `401` | `unauthorized` | Missing or invalid API key | | `403` | `forbidden` | Valid key, but you don't own this resource | | `403` | `insufficient_scope` | Valid key, but its [scopes](#scopes) don't cover this endpoint | | `429` | `rate_limited` / `queue_full` / `quota_exceeded` | Too many requests this minute, too many scenes processing at once, or a monthly quota used up | | `503` | `service_unavailable` | The key couldn't be checked right now, so this says nothing about it: a valid, unknown or revoked key gets the same answer. Retry after `Retry-After` seconds when the response carries one | ```json theme={null} { "error": { "code": "unauthorized", "message": "Authentication required." }, "meta": { "request_id": "abc123" } } ``` ## Best practices > [!TIP] > > Use environment variables for your API keys. Never hardcode them in source code or commit them to version control. * **One key per environment** -- separate keys for development, staging, and production * **Rotate regularly** -- revoke and replace keys periodically * **Use descriptive names** -- name keys after their purpose (e.g., "production-backend", "ci-pipeline") * **Monitor usage** -- the dashboard shows the `last_used_at` timestamp for each key so you can spot unused ones and revoke them # Build with AI agents Source: https://docs.splat-3d.com/build-with-ai Give a coding agent the docs as text, the Splat3D API skill, or a short prompt. # Build with AI agents Coding agents such as Claude Code, Cursor and Codex can write a Splat3D integration. Give yours one of the three below. ## The docs as text | URL | Holds | | - | - | | [`docs.splat-3d.com/llms.txt`](https://docs.splat-3d.com/llms.txt) | An index of every page, one line each | | [`docs.splat-3d.com/llms-full.txt`](https://docs.splat-3d.com/llms-full.txt) | Every page in one file | | [`api.splat-3d.com/openapi.json`](https://api.splat-3d.com/openapi.json) | The OpenAPI spec | Every page also has a markdown copy: add `.md` to its URL, e.g. `https://docs.splat-3d.com/quickstart.md`. ## The skill The `splat3d-api` skill holds the integration loop and the mistakes agents make without it, and links to these docs for the rest: [splat3d/examples/skills/splat3d-api](https://github.com/splat3d/examples/tree/main/skills/splat3d-api). For Claude Code, copy the `splat3d-api` folder into your project's `.claude/skills/`, or into `~/.claude/skills/` for every project. Agents that load `SKILL.md` files can use it the same way. ## A prompt For an agent without skill support, paste this: ```text theme={null} Build against the Splat3D API (https://api.splat-3d.com). Read https://docs.splat-3d.com/llms-full.txt first. - Auth: Authorization: Bearer s3d_..., read from an env var on the server. Never put the key in a browser, app bundle or repo. - Loop: POST /v1/scenes {content_type, file_size} -> PUT the file to data.uploadUrl with the same Content-Type -> POST /v1/scenes/{id}/process -> poll GET /v1/scenes/{id} every 5-10 s until status is complete, failed or cancelled -> GET /v1/scenes/{id}/download?format=sog|ply|spz. - /process charges once per scene. Repeat it only with the same body (a different one is 409). Never create a new scene to retry. - Errors: {error: {code, message, details?}, meta: {request_id}}. On 429 queue_full, wait Retry-After seconds and call /process again. On 429 rate_limited (300 requests or 30 launches a minute per account), wait Retry-After seconds and repeat the request. Retry a 503 only when it carries Retry-After. - Inputs: a video of 30+ seconds, a zip of 20+ JPEG/PNG photos taken all around the subject, a COLMAP workspace zip, or a SplatKing export zip as exported: https://docs.splat-3d.com/guides/supported-inputs ``` # Changelog Source: https://docs.splat-3d.com/changelog Changes to the Splat3D API, newest first. **API 1.7.0** **Breaking for small photo sets:** `/process` and `/retrain` refuse a photo zip with fewer than 20 JPEG or PNG photos with `422 source_unusable`, before anything is charged. They used to take 5. `details` holds one violation: `[{"field": "photo_count", "value": 12, "allowed": {"min": 20}, "hint": "..."}]`. After `/process` the scene stays `uploading`: upload a zip with more photos, taken all around the subject, to the same upload URL and call `/process` again. `/retrain` creates no new version. * A zip holding a COLMAP model skips SfM, so it still needs only 5: a `sparse//` folder with `cameras`, `images` and `points3D`, all `.bin` or all `.txt`, SplatKing LiDAR exports included. Loose `.bin` files at the zip's root are not a model. * A SplatKing video export (one `wide.mov`, under 5 photos) runs as a video and isn't counted. With 5 to 19 photos beside a `wide.mov`, the photos are processed, so the zip is refused. * A zip under 5 photos is refused as before, but `details` now states `"allowed": {"min": 20}`, unless it holds a COLMAP model. * A zip whose file list can't be read (ZIP64) launches as before. **Breaking for 360° video:** `/process` and `/retrain` refuse a 360° video at any length with `422 source_unusable`, before anything is charged. 360° video isn't supported yet: COLMAP has no equirectangular camera model and the trainer can't render one, so such a job can't succeed. A video whose frame, as shown, is 2:1 within 1% gets `[{"field": "video_aspect", "value": 2, "allowed": {"not": 2}, "hint": "..."}]`; one of another shape carrying Spherical Video V2 metadata (`sv3d`) gets `[{"field": "video_projection", "value": "spherical", "allowed": ["flat"], "hint": "..."}]`. A 360° video under 30 seconds gets this refusal, not `video_seconds`. A video whose header can't be read launches as before. * A photo zip of more than 400 photos is thinned to 400, evenly spread by name, first and last kept. It used to keep 800. Videos still keep up to 800 frames. * With no `sfm.options.matcher`, video of up to 200 frames (about 2 minutes 15 seconds) matches every pair of frames (`exhaustive`), as photos do. It used to match sequentially. Longer video still does. See [`sfm.matcher`](/guides/training-parameters#sfm-matcher). * **Fixed:** `GET` and `HEAD /v1/scenes` counted each request twice against the per-minute request limit, so listing reached `429 rate_limited` at about half the documented 300 a minute. Each request now counts once. * The spec's `info.version` is `1.7.0`. **API 1.6.0** **Breaking for keys created as Read:** the API checks a key's scopes, which it used to ignore. A request whose key lacks the scope an endpoint needs answers `403 insufficient_scope`, before it reads or changes any scene, quota or credit balance. Keys created with Write or All (the default) are unaffected. See [Scopes](/authentication#scopes). * `read:scenes` (Read): `GET /v1/scenes`, `GET /v1/scenes/{id}` and `/download`. * `write:scenes` (Write): those, plus create, `PATCH`, `DELETE`, `/process`, `/retrain` and `/cancel`. * `GET /v1/usage` and `/thumbnail` take any key, as before. * `insufficient_scope` joins the `error.code` list, and each guarded operation in the spec documents its `403`. **Breaking for short videos:** `/process` and `/retrain` refuse a video under 30 seconds with `422 source_unusable`, before anything is charged. `details` holds one violation: `[{"field": "video_seconds", "value": 12.4, "allowed": {"min": 30}, "hint": "..."}]`. After `/process` the scene stays `uploading`: upload a longer video to the same upload URL and call `/process` again. `/retrain` creates no new version. * The length is read from the MP4 or MOV header. A video whose length can't be read that way (WebM, a fragmented MP4 that records no total length, a damaged header) launches as before. * A SplatKing video export (a zip holding `wide.mov`) is not checked. * The spec's `info.version` is `1.6.0`. **API 1.5.0** Scenes in the list, `GET` and `PATCH` answers carry new fields, all nullable. Not breaking, unless a client refuses unknown keys: * `preset`: `fast`, `standard`, `quality`, `ultra` or `custom`, the preset the scene was created or retrained with. `null` when its stored parameters name none. * `psnr`: mean PSNR in dB over the training images, set on `complete` and kept if the scene later fails. In-sample, so usually higher than `psnr_holdout`. * `training_time_seconds`: seconds the GPU training stage took, set on `complete` and kept if the scene later fails. The whole job runs from `processing_started_at` to `processing_completed_at`. * `processing_started_at`: when processing was launched. `processing_completed_at`: when the scene was recorded complete, on `complete` only. * `spz_size_bytes`, `sog_size_bytes`, `ply_size_bytes`: each model file's size, on `complete` when the scene has that file. * The spec's `info.version` is `1.5.0`. **API 1.4.0** **Breaking:** `GET /v1/scenes/{id}/download` without `format` sends the scene's SPZ when it has one, otherwise its SOG, then its PLY. It used to send SOG, then PLY. `X-Splat-Format` names the file sent. `Content-Disposition` now follows that file, not `format`: an SPZ or PLY comes as an attachment, a SOG doesn't. `SceneDetail.format` names what `download_url` sends, so it can be `spz`. Pass `?format=sog` to keep the old file. `format=sog`, `ply` and `spz` send the files they did before, but a `format=ply` that falls back to SOG is no longer an attachment, and a `format=sog` that falls back to PLY now is. **Breaking for clients above it:** every `/v1` request counts against a per-minute limit: 300 requests per account (all its keys together), or per client IP for a request with no key or a key that matches no account, and within them 30 create, `/process` and `/retrain` requests per account. Past it the API answers `429 rate_limited` with `Retry-After: 60` and the limit in `meta.limit`, before it reads or changes any scene, quota or credit balance: repeat the request after `Retry-After`. Polling every 5 to 10 seconds stays well under it. The limits are approximate: a fast burst can pass one several times over before its first `429`. See [Rate limits](/authentication#rate-limits). * `X-RateLimit-Limit` names the route's real limit, `300`, or `30` on create, `/process` and `/retrain`, on every `/v1` response. It read `100` on authenticated responses and limited nothing. There is no `X-RateLimit-Remaining`. * Browsers on splat-3d.com can read `Retry-After` and `X-RateLimit-Limit`: CORS now exposes them. * Every `/v1` operation in the spec documents the `429` with `Retry-After` and `X-RateLimit-Limit`, and `rate_limited` joins the `error.code` list. * The spec's `info.version` is `1.4.0`. **API 1.3.0** **Breaking:** `DELETE /v1/scenes/{id}` on a scene another account owns answers `404 not_found`, with the same body as an unknown id. It used to answer `403 forbidden`, which told the caller the scene existed. * `DELETE` is a soft delete, as in the dashboard: every call answers `404` for the scene at once, and a daily cleanup removes its files 24 to 48 hours later. An unfinished scene is still cancelled first, under the cancel refund rules. * A database failure while deleting answers `503 service_unavailable` with `Retry-After`. A failed read used to answer `404`, a failed delete `500`. * `DELETE` can answer `409 conflict` when the scene's status changes while it is being deleted. Try again. **Breaking for loose request bodies:** these now answer `400 invalid_input`, where they used to be accepted. Nothing is created, claimed or charged. * A create, `PATCH`, `/process` or `/retrain` body with a key the endpoint doesn't take, e.g. `{"presets": "ultra"}`. `error.details` names each key as `{"field": "presets", "message": "unrecognized key"}`. These keys used to be dropped, so that body made a `standard` scene. * A `webhook_url` that isn't `https://` (any letter case): `http://` (`localhost` included) and `ftp://` are refused. Deliveries come from our servers, which can't reach your machine: test through a tunnel's https URL. * A `training_model` other than `gsplat`, e.g. `fastgs`. It used to be stored, or replaced by `gsplat`; every scene trains with gsplat. * A `training_tier` other than its legacy aliases `fast`, `standard`, `quality`, `pro`, `hd` and `ultra`, e.g. `ultraa`. It used to make a `standard` scene. With the stricter bodies, not breaking: * Create takes `null` for `title` and `address`, meaning none. It answered `400 invalid_input`. * A create whose `sfm`, `training` or `output` fails a check, e.g. `training.sh_degreee` or `training.iterations: 500`, lists each problem in `error.details` as `{field, message}`, as other `400 invalid_input` answers do. * The spec marks the create, `PATCH`, `/process` and `/retrain` bodies `additionalProperties: false` at the top level (create's `sfm`, `training` and `output` stay open objects in the spec; the server refuses their unknown keys), lists `gsplat` as the one `training_model` and the `training_tier` aliases, and gives `webhook_url` a `pattern` requiring `https://`. **Breaking:** scenes no longer carry `preview_ready`. The API never served the preview it announced, and the pipeline no longer makes one, so it would read `false` on every new scene. Drop it from clients that require the key; `thumbnail_url` is the image to show while a scene processes. New scenes no longer pass through the `preview_*` statuses. **Breaking:** `sfm.options.max_image_size` and `sfm.options.matcher` are held to the [tier envelope](/guides/training-parameters#per-tier-parameter-envelopes) of `sfm.max_image_size` and `sfm.matcher`. A create outside it answers `422 invalid_input`, its violation's `field` naming `sfm.options.max_image_size` or `sfm.options.matcher`, and makes no scene. It used to be accepted, so a `fast` scene could run full-resolution SfM (`0`) or `exhaustive` matching on a video. * The flat key and the options key are checked separately: SfM reads `sfm.options`, and `sfm.max_image_size` sets only the training resolution. * The floor applies too: `sfm.options.max_image_size` from `400` to `511`, which SfM accepts, is refused on `standard`, `quality` and `ultra`, whose floor is `512`. * `/retrain` drops an inherited `sfm.options.max_image_size` or `sfm.options.matcher` that the target tier refuses. Other `sfm.options` keys still carry over. Additive: launch answers carry snake\_case keys. Create answers `scene_id`, `id` and `upload_url`, `/process` answers `scene_id` and `id`, and `/retrain` answers `scene_id` beside `id`, plus `source_scene_id`, the scene it retrained. `sceneId` and `uploadUrl` stay, marked deprecated: read the snake\_case keys. * `/retrain` takes `preset` (`fast`, `standard`, `quality`, `ultra`) beside `quality_tier`, priced as the tier it names (`quality` is `pro`). Sent together, they must name the same tier, or the request answers `400 invalid_input`. * **Fixed:** a `/retrain` refused before any charge (`402`, `429 quota_exceeded`, or a `503` before the launch was reserved) keeps no new scene and counts nothing against the scene-creation quota. It used to leave an `uploading` version; only `429 queue_full` removed it. * **Fixed:** an empty body on `/process` or `/retrain` with `Content-Type: application/json` means `{}`, as no body does. It answered `400 invalid_input` since 1.1.0. * `/process` answers its `503`s with `service_unavailable`, not `internal_error`. A `/retrain` whose read of the source scene fails answers `503 service_unavailable`, not `404`. * The `/retrain` description no longer says it cancels a run in progress: it never did. It makes a new scene and leaves the source as it is. * The spec's `info.version` is `1.3.0`. **API 1.2.0** **Breaking for generated clients:** the spec is OpenAPI 3.1. It said `3.1.0` before but held 3.0 schemas. Nullable fields are type arrays (`"type": ["string", "null"]`), not `nullable: true`, and `SceneDetail.format` lists `null` in its enum. Regenerate clients from the new spec. Responses don't change. * The spec has a `webhooks` section: [`scene.completed`](/api-reference/webhooks/scene-completed) and [`scene.failed`](/api-reference/webhooks/scene-failed), with each payload field and the `X-Splat-Signature` header. See [Webhooks](/api-reference/overview#webhooks) for how to verify it. * `GET /v1/usage`: `gpu_seconds_used` and `storage_bytes` are deprecated. Nothing counts them, so they read `0`. `limits.gpu_seconds` and `limits.storage_bytes` are deprecated too: nothing enforces them. * The thumbnail is documented as `image/png`, not JPEG. * The spec's `info.version` is `1.2.0`. **API 1.1.0** **Breaking:** a request that fails an endpoint's schema answers `400` in the documented [error envelope](/api-reference/overview#error-responses), with code `invalid_input` and one `{field, message}` per problem in `error.details`. It used to answer `{"success": false, "error": {"issues": [...], "name": "ZodError"}}`. Read `error.details` where you read `error.issues`. **Breaking:** one scene shape, status and cursor for every call. * `GET /v1/scenes/{id}` returns the stored `status`, as the list and `PATCH` do. It no longer answers `processing` or `preview_ready`. Live progress is in `processing_stage` and `processing_pct`; `preview_ready` is now a boolean field. * `processing_stage` and `processing_pct` are `null` unless the scene is processing. A complete scene used to keep its last stage, e.g. `compressing` and `98`. * List items have the shape `GET /v1/scenes/{id}` returns (`SceneDetail`): they gain `ssim`, `psnr_holdout`, `ssim_holdout`, `processing_error`, `viewer_url`, `download_url`, `format` and `lod_meta_url`. * Every scene carries `training_model`, `description`, `preview_ready` and `thumbnail_url`. * `created_at` and `updated_at` are UTC with milliseconds (`2026-04-13T12:00:00.000Z`), not microseconds with `+00:00`. * `next_cursor` is opaque: pass it back unchanged. Timestamp cursors still work. A cursor the API can't read answers `400 invalid_input`, not `500`. * A JSON body that doesn't parse (malformed, or empty with `Content-Type: application/json`) answers `400 invalid_input`, not `500`. * A database failure while listing, creating or updating a scene answers `503 service_unavailable`, not `500 internal_error`. The list carries `Retry-After`. Create and `PATCH` don't: check `GET /v1/scenes` before repeating a create. * The OpenAPI spec declares `400` and `500` wherever they can occur, `402` and `502` on `/process`, and `402`, `413` and `502` on `/retrain`. `ErrorResponse.meta` declares the `429` fields (`period`, `limit`, `field`, `current`), and the `error.code` description lists every code the API sends. * The spec's `info.version` is `1.1.0`. Endpoints stay under `/v1`. # Output Formats Source: https://docs.splat-3d.com/guides/output-formats Understand the SOG, PLY and SPZ formats and when to use each. # Output Formats Every scene gets two output formats: **SOG** (compressed, web-optimized) and **PLY** (raw Gaussian Splat data). Most can also be downloaded as [SPZ](#spz), for native viewers. The `output.formats` parameter is accepted but has no effect: nothing reads it (`_upload_to_r2` in `pipeline/storage/upload.py`). ## Format comparison | | SOG | PLY | SPZ | | - | - | - | - | | **File size** | 5-15 MB | 30-80 MB | Between SOG and PLY | | **Web loading** | Fast (optimized) | Slow (large) | Not used by the web viewer | | **Browser support** | All modern browsers | All modern browsers | Native viewers | | **Editable** | No | Yes | No | | **Use case** | Web delivery, embedding | Offline tools, editing, research | Native and mobile renderers | ## SOG (Super-Optimized Gaussians) SOG is a compressed format designed specifically for fast web delivery. It uses quantization and efficient packing to reduce file sizes by 3-5x compared to raw PLY while maintaining visual quality. **Characteristics:** * Typically 5-15 MB for a full scene * Loads in 1-3 seconds on a typical connection * Renders with the PlayCanvas gsplat viewer * Lossy compression -- fine details may be slightly reduced **When to use:** For anything that loads in a browser. Web apps, embeds, shareable links, mobile viewers. A download sends it when you don't name a format and the scene has no SPZ. ## PLY (Polygon File Format) PLY is the standard interchange format for Gaussian Splats. It stores the full, uncompressed Gaussian parameters (position, covariance, color, opacity, spherical harmonics). **Characteristics:** * Typically 30-80 MB depending on the number of Gaussians * Contains all Gaussian parameters at full precision * Compatible with all major Gaussian Splat viewers and editors * Lossless -- exact training output **When to use:** When you need to post-process, edit, or analyze the Gaussian Splat data. Research, offline rendering, format conversion, or feeding into other tools. ## Downloading When downloading without `format`, the scene's SPZ is served, otherwise its SOG, then its PLY; the `X-Splat-Format` header names the file sent. The scene's `format` field names the file expected, but the header decides: a scene whose SPZ is missing from storage reports `spz` and sends SOG. Add `?format=sog` or `?format=ply` to the download URL for that file. The endpoint answers `200` with the file itself (`downloadScene` in `api/src/lib/scenes.ts`); pass `-o ` to write it to a known path: ```bash theme={null} # SOG curl -L -o scene.sog \ "https://api.splat-3d.com/v1/scenes/$SCENE_ID/download?format=sog" \ -H "Authorization: Bearer $SPLAT3D_API_KEY" # PLY curl -L -o scene.ply \ "https://api.splat-3d.com/v1/scenes/$SCENE_ID/download?format=ply" \ -H "Authorization: Bearer $SPLAT3D_API_KEY" ``` ## SPZ SPZ is [Niantic's compressed format](https://github.com/nianticlabs/spz) for Gaussian Splats: smaller than PLY, larger than SOG. Use it with viewers that load `.spz` files directly, such as native renderers. On iOS, [SplatKit](https://github.com/Xget7/splatkit-ios) renders SPZ natively (iOS 17, A14 or M1 chip or newer). SPZ is the default download. After training, every scene is converted to SPZ on a best-effort basis, so some older scenes, and scenes whose conversion failed, have none. For those, a download without `format` sends SOG, while `?format=spz` returns `404 not_found`; it never sends another format instead. ```bash theme={null} curl -L -o scene.spz \ "https://api.splat-3d.com/v1/scenes/$SCENE_ID/download?format=spz" \ -H "Authorization: Bearer $SPLAT3D_API_KEY" ``` ## Viewing SOG files SOG files are rendered using the [PlayCanvas gsplat viewer](https://github.com/playcanvas/supersplat). You can: * **View on Splat3D** -- every completed scene has a shareable link at `https://splat-3d.com/tour/{sceneId}` * **Embed in your app** -- use the PlayCanvas viewer library directly * **Use SuperSplat** -- the open-source desktop viewer from PlayCanvas ## Working with PLY files PLY files are compatible with a wide ecosystem of tools: * **[SuperSplat](https://playcanvas.com/supersplat/editor)** -- browser-based editor for Gaussian Splats * **[3D Gaussian Splatting Viewer](https://github.com/graphdeco-inria/gaussian-splatting)** -- the original INRIA viewer * **[Nerfstudio](https://docs.nerf.studio/)** -- for further training or analysis * **Custom pipelines** -- PLY is a standard format readable by most 3D libraries > [!TIP] > > For web delivery, always use SOG. Download PLY only when you need the raw data for offline processing or compatibility with third-party tools. # How the Splat3D pipeline works Source: https://docs.splat-3d.com/guides/pipeline-architecture From upload to interactive 3D scene — what each stage does and how long it takes. # How the Splat3D pipeline works When you upload a video or photo set, Splat3D runs it through a sequence of stages that turn pixels into a 3D scene you can fly through. This guide gives you enough context to predict runtimes and pick the right [quality tier](/guides/presets) for your workload. ## At a glance ``` Upload (video or photos) │ ▼ 1. Frame extraction 2. Structure-from-Motion (find where each photo was taken) 3. Gaussian Splat training (build the scene) 4. Cluster cleanup 5. Compression for the web 6. LOD generation (optional) │ ▼ Scene is ready to view + share ``` ## Stage 1 — Frame extraction For video, frames are sampled at roughly 1.5 frames per second and capped at 800 frames (`MAX_FRAMES` in `pipeline/training/frame_extract.py`). Blurry frames are filtered out. For photo zips, up to 400 images are extracted (`MAX_PHOTOS`; a bigger set is thinned evenly) and EXIF orientation is applied so portrait photos render right-side-up. If the zip holds a COLMAP model (`images/` plus `sparse//`), the SfM stage is skipped and we go straight to training. Such a zip is not thinned: over 800 registered images, the scene fails (`MAX_REGISTERED_IMAGES` in `pipeline/colmap/prebuilt.py`). ARKit poses don't skip it: GLOMAP still runs, and the poses set which way is up (see [Supported inputs](/guides/supported-inputs#arkit-capture)). Runtime: seconds. ## Stage 2 — Structure-from-Motion Splat3D figures out where each photo was taken from (camera poses) and produces a sparse 3D point cloud that anchors the next stage. Without accurate poses, training cannot produce a useful scene. The flow: 1. Detect distinctive keypoints in each image. 2. Match keypoints between images. For photo zips and video of up to 200 frames, match every pair. For longer video, match each frame to its temporal neighbors. 3. Solve a global optimization that places every camera and every 3D point such that the math is consistent across all observations. Runtime: 4–10 minutes depending on image count. Photo zips with many images take longer because matching is exhaustive (cost grows with the square of the image count). ## Stage 3 — Gaussian Splat training Now that we know where every camera is, we fit the scene representation: a few million 3D Gaussians, each with position, size, color, and opacity. Training is iterative: 1. Render the current Gaussians from a known camera angle. 2. Compare the result to the actual photo from that angle. 3. Update the Gaussians to reduce the difference. 4. Periodically split, clone, or remove Gaussians. 5. Repeat for thousands of iterations. The number of iterations and the resolution scale with the quality tier: | Preset | Iterations | Image budget | Whole pipeline | | - | - | - | - | | `fast` | 3K | 800 px | \~8 min | | `standard` | 7K | 1600 px | \~15 min | | `quality` | 15K | 1600 px | \~25 min | | `ultra` | 30K | full-res | \~45 min | The times cover every stage, `/process` to ready, and are approximate: they depend on video length, the number of frames and GPU availability (`TIER_TABLE` in `packages/core/src/tiers.ts`). Higher tiers fit finer detail and more reflective surfaces but cost more credits. Start at Standard and move up if you need sharper results. > [!NOTE] > > Need higher resolution, larger captures, or a custom training > configuration for archival or research work? Email > [hello@splat-3d.com](mailto:hello@splat-3d.com) and tell us about your > use case. ## Stage 4 — Cluster cleanup Trained scenes can have detached "floater" Gaussians — distant artifacts disconnected from the main subject. Splat3D analyzes the scene topology and records the result alongside the model so the viewer can hide or prune floaters. By default this is advisory, not destructive — the analysis is attached to the scene but no Gaussians are removed automatically. Runtime: seconds. ## Stage 5 — Compression for the web Trained models (PLY files) range from tens of megabytes to several gigabytes depending on tier and scene complexity. That's too large to load instantly in a web viewer, so Splat3D compresses the model to a SOG file (1–10 MB) with negligible quality loss. The viewer always loads the SOG. The original PLY remains available as a direct download for users who want the raw training output. Runtime: \~30 seconds. ## Stage 6 — LOD generation (optional) For very large scenes, Splat3D can build a level-of-detail structure that streams the scene in chunks based on camera distance, keeping the framerate steady on huge models. Disabled by default; opt in per scene. ## Practical takeaways * **Photo uploads scale quadratically** with image count due to exhaustive matching, so a zip keeps at most 400 photos (a bigger set is thinned evenly). Send 400 or fewer to choose which ones are used. * **Video uploads are capped at 800 sampled frames** — there is no benefit to uploading hour-long videos. * **Quality tier picks the speed/fidelity trade-off.** All tiers run the same SfM stage; they differ in training iterations, resolution, and total runtime. See [quality presets](/guides/presets) for the recommended tier per use case. ## Further reading * [SfM backends](/guides/sfm-backends) — override the matcher and other SfM options on a per-scene basis. * [Training parameters](/guides/training-parameters) — knobs on the training stage. * [Output formats](/guides/output-formats) — what files come out of the pipeline and how to use each one. # Quality Presets Source: https://docs.splat-3d.com/guides/presets Choose the right speed/quality tradeoff for your 3D reconstructions. # Quality Presets Presets are the simplest way to control the quality of your 3D scene. Each preset configures resolution, training iterations, the GPU and holdout metrics as a bundle. Every preset produces the same files: SOG and PLY, and SPZ on a best-effort basis (see [Output Formats](/guides/output-formats)). ## Preset comparison | Preset | Resolution | Iterations | Time | Best for | | - | - | - | - | - | | `fast` | 800px | 3,000 | \~8 min | Quick previews, testing | | `standard` | 1600px | 7,000 | \~15 min | Production use, good balance | | `quality` | 1600px | 15,000 | \~25 min | High-fidelity scenes | | `ultra` | Full resolution | 30,000 | \~45 min | Maximum quality, archival | > [!NOTE] > > Times are approximate and depend on video length, number of frames extracted, and GPU availability. ## Using presets Pass the `preset` field when creating a scene: ```json theme={null} { "title": "Living Room Tour", "content_type": "video/mp4", "preset": "quality" } ``` ## Overriding individual parameters Presets set sensible defaults, but you can override any individual parameter. The preset values are used as the base, and your overrides are applied on top. ```json theme={null} { "title": "Custom Scene", "content_type": "video/mp4", "preset": "standard", "training": { "iterations": 10000 } } ``` This gives you standard preset defaults (1600px, GLOMAP) but with 10,000 training iterations instead of 7,000. ## The `custom` preset When you need full control, use `"preset": "custom"`. This starts from the `standard` defaults and applies all your overrides: ```json theme={null} { "title": "Full Control", "content_type": "video/mp4", "preset": "custom", "sfm": { "max_image_size": 1600, "options": { "matcher": "sequential" } }, "training": { "iterations": 9000, "sh_degree": 3 }, "output": { "holdout_ratio": 0.15 } } ``` > [!NOTE] > > `custom` runs on the **`standard` tier's GPU**, so overrides are checked > against the `standard` [parameter envelope](/guides/training-parameters#per-tier-parameter-envelopes) > (e.g. `iterations` 3,000–10,000, `sh_degree` 0–3). To push beyond that — more > iterations, `sh_degree: 4`, or higher resolution — choose `"preset": "quality"` > or `"preset": "ultra"` and override on top of those instead. ## Preset details ### Fast Best for quick iteration. Low resolution and few training iterations mean you get results in under 10 minutes, but fine details will be lost. * Resolution: 800px max dimension * 3,000 training iterations * No quality metrics computed ### Standard The default. A good balance of speed and quality for most use cases. This is what the web dashboard uses. * Resolution: 1600px max dimension * 7,000 training iterations * PSNR metrics computed with 10% holdout ### Quality For scenes where visual fidelity matters. Same resolution as standard but significantly more training iterations for sharper details and fewer artifacts. * Resolution: 1600px max dimension * 15,000 training iterations * Metrics computed ### Ultra Maximum quality. Uses full-resolution images (no downscaling) and 30,000 training iterations. Best for archival or when every detail counts. * Full resolution (no downscaling) * 30,000 training iterations * Metrics computed > [!WARNING] > > Ultra preset processes significantly longer and produces larger files. Only use it when maximum quality is genuinely needed. ## Which preset should I use? > **Testing & prototyping** > > Use **fast**. Get results quickly to validate your capture workflow. > **Production apps** > > Use **standard**. The best balance for web-delivered 3D scenes. > **Showcase content** > > Use **quality**. When the scene is customer-facing and needs to look great. > **Archival & research** > > Use **ultra**. When file size and processing time don't matter. > [!NOTE] > > Need higher resolution, larger captures, or a custom training > configuration that the standard presets don't cover? Email > [hello@splat-3d.com](mailto:hello@splat-3d.com) and tell us about your > use case. # Pricing & Credits Source: https://docs.splat-3d.com/guides/pricing Pay-as-you-go credits, the price of a scene at each quality tier, and the two credit packs. # Pricing & Credits Splat3D is pay-as-you-go. You buy credits, and each scene you process spends some of them. **1 credit = \$0.10.** You're only charged when you process a scene. Uploading is always free. ## How you pay > **Signup credits** > > **100 credits, once.** Every new account gets them when it signs up: 5 Standard scenes, or 10 Fast. No card required. > **Credit packs** > > Buy a pack when you need more. No subscription. Credits never expire. > [!NOTE] > > Signup credits are a one-time grant. They don't refill every month. ## Cost per scene Each quality preset uses a different amount of compute, so each costs a different number of credits. | Quality | Credits | Price per scene | What you get | | - | - | - | - | | **Fast** | 10 | \$1 | Quick preview, 800px | | **Standard** | 20 | \$2 | Production quality, 1600px | | **Quality** | 40 | \$4 | High fidelity, 1600px | | **Ultra** | 70 | \$7 | Maximum quality, full resolution | Every tier produces the same files: SOG, PLY and, on a best-effort basis, SPZ. > [!NOTE] > > Prices are at \$0.10 per credit, the rate of the 100-credit pack. The > 500-credit pack costs 20% less per credit. For approximate runtimes per tier, see the [pipeline architecture guide](/guides/pipeline-architecture). ## Credit packs Buy credits in bulk and use them whenever you want. **Credits never expire.** | Credits | Price | Standard scenes | Savings | | - | - | - | - | | **100** | \$10 | 5 | — | | **500** | \$40 | 25 | 20% off | Buy packs on the [billing dashboard](https://splat-3d.com/dashboard/billing). ## Need volume? Contact us at [hello@splat-3d.com](mailto:hello@splat-3d.com). ## Upload size limits Each scene's source file has a size cap. See [Upload limits](/guides/upload-limits). ## FAQ > **When are credits deducted?** > > Credits are deducted when you hit **Process**, not when you upload. > Uploading is always free, and the upload page shows each quality > tier's price before you start. > **What happens if processing fails?** > > If a scene fails, its credits are automatically refunded. > **Can I mix quality tiers?** > > Yes. Each scene is processed independently. You can use Fast for > quick tests and Quality for final renders, all from the same credit > balance. > **What are the export formats?** > > Every scene, at every tier, gets a compressed web format (SOG) for > instant browser viewing and a raw 3D file (PLY) you can open in Blender, > Unity, or other 3D tools. Most also get SPZ, a compact format for native > viewers; it is made on a best-effort basis, so a few scenes have none. # SfM Backends Source: https://docs.splat-3d.com/guides/sfm-backends Choose the right structure-from-motion engine for your captures. # SfM Backends Structure-from-Motion (SfM) is the first stage of the pipeline. It takes your images and estimates where each camera was positioned in 3D space. These camera poses are then used to train the Gaussian Splat model. Two SfM backends run today: ## Backend comparison | Backend | Speed | Quality | Best for | | - | - | - | - | | `colmap_sift_glomap` | Fast | Excellent | Every upload, ARKit captures included (default) | | `prebuilt_colmap` | Instant | Yours | A COLMAP model you already built | > [!NOTE] > > `prebuilt_colmap` is selected automatically when your zip contains a > `sparse/` model — it is not something you request via `sfm.backend`. > The pipeline also registers `none_arkit`, a pose passthrough, but no API > input gives it the model it copies, so selecting it fails the scene > (refunded; `NoneArkitBackend.run` in `sfm_backends/none_arkit.py`). > [!NOTE] > > The API also accepts legacy short-names for backward compatibility: > `glomap` and `colmap` both resolve to `colmap_sift_glomap`, and `none` > resolves to `none_arkit`. Prefer the canonical names in new code; the > `colmap` alias emits a deprecation warning and may be removed. ## `colmap_sift_glomap` (default) GPU SIFT feature extraction (via [COLMAP](https://colmap.github.io/)) combined with [GLOMAP](https://github.com/colmap/glomap)'s modern global mapper, which solves all camera poses simultaneously rather than incrementally. ```json theme={null} { "sfm": { "backend": "colmap_sift_glomap" } } ``` **Advantages:** * Significantly faster than incremental COLMAP * GPU SIFT for feature extraction * Global optimization is more robust for video walkthroughs * Default for all presets **When to use:** Almost always. This is the recommended backend for video input and most photo sets. If you don't override `sfm.backend`, this is what runs. ### Options Set these in `sfm.options`. SfM reads only this object: the top-level `sfm.matcher` is ignored, and `sfm.max_image_size` sets only the training resolution (`SfmRequest.from_api_payload` in `sfm_backends/configs.py`). | Key | Type | Default | Description | | - | - | - | - | | `matcher` | `string` | By input | `exhaustive` or `sequential`. Unset, photos and video up to 200 frames get `exhaustive`, longer video `sequential`. `exhaustive` is for `standard` and above, as with `sfm.matcher` | | `max_image_size` | `integer` | `1600` | Longest edge, in pixels, for feature extraction: `400`-`4096` | The backend also accepts tuning keys for COLMAP and GLOMAP (`camera_model`, `max_num_features`, triangulation thresholds and others, in `ColmapSiftGlomapOptions`); they are unsupported and may change. An unknown key or bad value is refused when `/process` dispatches the job: `400 invalid_input` (`Invalid sfm options: ...`), and the charge is refunded (`translatePydantic422` in `api/src/lib/scenes.ts`). ## Camera poses you already have There are two ways to supply poses. Only a COLMAP model skips SfM. ### Path A: ARKit poses via API (recommended for iOS) Pass camera poses directly in the `POST /scenes/{id}/process` request body. The API stores them server-side. No zip required — upload your video as normal. GLOMAP still solves the camera poses from the frames (`_stage_sfm` in `pipeline/training/impl.py`); the ARKit poses set which way is up and, for a video, which frames are cut: up to 150, at the pose timestamps (`_extract_arkit_timestamp_frames`). ```bash theme={null} # 1. Create a scene (standard video upload) curl -X POST https://api.splat-3d.com/v1/scenes \ -H "Authorization: Bearer s3d_..." \ -H "Content-Type: application/json" \ -d '{"title": "My scan", "preset": "standard", "content_type": "video/mp4"}' # Response includes sceneId + uploadUrl # 2. Upload video directly to the presigned URL curl -X PUT "$UPLOAD_URL" \ --upload-file capture.mp4 \ -H "Content-Type: video/mp4" # 3. Process with ARKit poses. poses.json is {"arkit_poses": [...]}, holding # 5 to 1,000 poses shaped like the one below. curl -X POST https://api.splat-3d.com/v1/scenes/$SCENE_ID/process \ -H "Authorization: Bearer s3d_..." \ -H "Content-Type: application/json" \ -d @poses.json ``` One pose: ```json theme={null} { "timestamp": 0.033, "file_path": "frame_000001.jpg", "transform": [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1], "intrinsics": [1674,0,960, 0,1674,720, 0,0,1], "width": 1920, "height": 1440 } ``` **Pose format:** | Field | Type | Description | | - | - | - | | `timestamp` | `number` | Capture time in seconds | | `file_path` | `string` | Path in the zip of the image this pose corresponds to (the file name alone works when it is unique) | | `transform` | `number[16]` | 4×4 camera-to-world matrix, **column-major** (matches ARKit `simd_float4x4` layout), in a gravity-aligned world with +Y up | | `intrinsics` | `number[9]` | 3×3 intrinsics, **row-major**: `[fx, 0, cx, 0, fy, cy, 0, 0, 1]` | | `width` | `integer` | Image width in pixels | | `height` | `integer` | Image height in pixels | Minimum 5 poses, maximum 1000. You don't set `sfm.backend`: the API sends the poses with the job, and the pipeline runs GLOMAP as for any upload. **The scene's orientation comes from these poses.** The pipeline reads gravity off the world's +Y axis, so record them in an ARKit session with `worldAlignment` set to `.gravity` (the default) or `.gravityAndHeading`, not `.camera`. It needs at least 30 frames or photos paired with a pose and a median tilt residual (each pair's up against the fitted up) of 3° or less; otherwise it estimates up from the cameras, which lays portrait captures on their side (`MIN_PAIRS`, `MAX_MEDIAN_TILT_DEG` in `pipeline/training/arkit_gravity.py`). Photos are used as stored: EXIF orientation is ignored when `arkit_poses` is set, so each image's pixels must be in the camera axes its `transform` describes, as ARKit's `capturedImage` is. If you're building an iOS app, the [Splat3D Swift SDK](/sdks/overview) handles all of this for you — ARKit capture, pose encoding, upload, and polling in one call. *** ### Path B: COLMAP zip upload If you've already run COLMAP (or Metashape, RealityCapture, or your own SfM code that exports COLMAP binaries), bundle your images and sparse model into a zip. The pipeline detects the `sparse/` model, skips SfM entirely, and starts training immediately. This is the fastest path to a splat and gives you full control over the reconstruction. **Zip structure:** ``` your-upload.zip ├── images/ │ ├── frame_001.jpg │ ├── frame_002.jpg │ └── ... └── sparse/ └── 0/ ├── cameras.bin ├── images.bin └── points3D.bin ``` A single wrapper folder is fine (`my-scene/images/` + `my-scene/sparse/0/`) — zip the folder or its contents, whichever your tool produces. If several models are present, `sparse/0` is used; among several `sparse/0` folders, the one with `images/` beside its `sparse/`. Two tool outputs also work zipped as they are: * **Inria 3DGS `convert.py`** — the undistorted `sparse/0` and `images/` are used; `input/` and `distorted/` are ignored. * **nerfstudio `ns-process-data`** — the model in `colmap/sparse/0` is paired with the photos in the top-level `images/`; `images_2/` and the other downscaled copies are ignored. ```bash theme={null} # 1. Create a scene — content_type marks it as a photo/zip upload curl -X POST https://api.splat-3d.com/v1/scenes \ -H "Authorization: Bearer s3d_..." \ -H "Content-Type: application/json" \ -d '{ "title": "My scene", "preset": "standard", "content_type": "application/zip" }' # 2. Upload zip to presigned URL curl -X PUT "$UPLOAD_URL" \ --upload-file my-scene.zip \ -H "Content-Type: application/zip" # 3. Trigger processing (no body needed — the model is detected in the zip) curl -X POST https://api.splat-3d.com/v1/scenes/$SCENE_ID/process \ -H "Authorization: Bearer s3d_..." ``` You do **not** need to set `sfm.backend` — detection is automatic. Uploading a zip with no `sparse/` directory runs the normal GLOMAP path. #### Requirements Your model is validated before training starts, so a mismatch fails fast with a message instead of burning GPU minutes: | Requirement | Detail | | - | - | | Format | COLMAP binary (`.bin`) or text (`.txt`). A text model is converted to binaries before validation; binaries win when a folder has both. | | Completeness | All three of `cameras.bin`, `images.bin`, `points3D.bin`, or of `cameras.txt`, `images.txt`, `points3D.txt`. An incomplete set of text files is ignored, and the photos go through SfM. | | Filenames | Every name in `images.bin` must exist under `images/`, matched as a full relative path — `left/frame_001.jpg` in the model needs `images/left/frame_001.jpg` in the zip, not a flattened `frame_001.jpg`. | | Camera models | `SIMPLE_PINHOLE`, `PINHOLE`, `SIMPLE_RADIAL`, `RADIAL`, `OPENCV`. Fisheye and other models are rejected — undistort to `PINHOLE` first with `colmap image_undistorter`, then zip its output folder: the undistorted `images/` together with its `sparse/` model. The original photos do not match an undistorted model. | | Photo size | Each photo must be the width × height its camera has in `cameras.bin`, or a uniform resize of it. Photos are read as stored, without applying EXIF rotation, so zip the exact files the model was built from. | | Registered images | Between 5 and 800. We cannot subsample a model for you — dropping an image would orphan its pose — so trim it yourself before uploading. | | Sparse points | Between 1,000 and 5,000,000 in `points3D.bin`. Training starts from one Gaussian per point, with no random initialization, so a poses-only export is rejected — triangulate points first with `colmap point_triangulator`, which needs a `--database_path` holding features and matches for the same photos (`colmap feature_extractor`, then a matcher such as `colmap exhaustive_matcher`). Thin a denser cloud with `colmap point_filtering`. | Photos in the zip that the model never registered are ignored. **Getting COLMAP binary output:** ```bash theme={null} # Run COLMAP and export binary sparse reconstruction colmap automatic_reconstructor \ --workspace_path ./colmap_output \ --image_path ./images # Binary files are written to colmap_output/sparse/0/ by default ``` *** ### When to use pre-supplied poses | Scenario | Recommended path | | - | - | | iOS app with LiDAR (iPhone 12 Pro+) | Path A — ARKit poses via API | | Existing COLMAP reconstruction | Path B — COLMAP zip | | Custom SLAM / visual odometry | Path B — convert to COLMAP binary format | | Re-training with different parameters | Path B — reuse existing sparse reconstruction | ## Matching strategies Feature matching only happens when SfM actually runs, so a COLMAP model zip (`prebuilt_colmap`) ignores these settings. Otherwise you can control the matching strategy in `sfm.options`: | Matcher | Description | Speed | Best for | | - | - | - | - | | `exhaustive` | Compare every image pair | Slowest | Small-medium scenes (under 200 images) | | `sequential` | Compare adjacent frames only | Fastest | Ordered video frames | ```json theme={null} { "sfm": { "options": { "matcher": "sequential" } } } ``` > [!TIP] > > Video of up to 200 frames gets `exhaustive` when you set none: on the videos we tested it registered as many frames as `sequential` or more. Longer video gets `sequential`, which stays fast as frames grow. Photo sets get `exhaustive`. # Supported Inputs Source: https://docs.splat-3d.com/guides/supported-inputs The files a scene can be built from, how to package each, and the content_type to send. # Supported Inputs A scene is built from one uploaded file: a video, a zip of photos, or a zip holding a COLMAP workspace. A video or photo zip can also come with ARKit camera poses. The `content_type` you send to `POST /v1/scenes` decides how the file is read. ## Which input should I use? | You have | Upload | `content_type` | | - | - | - | | A video from a phone or camera | [Video](#video) | `video/mp4` (any `video/*`) | | Photos from a camera | [Photo zip](#photo-zip) | `application/zip` | | An iOS app running ARKit | [Video plus `arkit_poses`](#arkit-capture) | `video/mp4` | | A COLMAP model you already built | [COLMAP workspace zip](#colmap-workspace-zip) | `application/zip` | | A SplatKing LiDAR or video capture | [SplatKing export zip](#splatking-export), as exported | `application/zip` | ## Rules for every input * **`content_type`**: `video/*`, `application/zip` or `application/x-zip-compressed`. Anything else is `400 invalid_input`, except `application/x-r3d`: create accepts it, but the pipeline has no Record3D reader and hands the file to its video decoder (`_stage_extract_frames`). Don't use it. Leave `content_type` out and the scene is read as a video (`createScene` in `api/src/lib/scenes.ts`). * **The PUT sends the same `Content-Type`.** The upload URL signs it, so storage refuses a PUT with any other type (`generatePresignedUploadUrl`). * **The upload URL lasts 24 hours** (`UPLOAD_URL_TTL_SECONDS`, `api/src/lib/r2-presign.ts`). A second PUT to it before `/process` replaces the file. * **At most 5 GB** (5,363,466,240 bytes), the same on every plan (`MAX_UPLOAD_BYTES`, `packages/core/src/uploads.ts`). See [Upload limits](/guides/upload-limits). * **At most 800 frames or 400 photos are kept** (`MAX_FRAMES`, `MAX_PHOTOS`, `pipeline/training/frame_extract.py`). Bigger sets are thinned evenly. A COLMAP workspace is the exception: over 800 registered images is refused. ### Where each check runs `/process` refuses what it can see before it charges anything. The rest fails the job, and a failed scene's credits are refunded. | Problem | Caught at | Answer | | - | - | - | | Declared `file_size` over 5 GB | create | `400 invalid_input` | | Unsupported `content_type` | create | `400 invalid_input` | | Nothing uploaded | `/process` | `400 source_missing` | | File over 5 GB | `/process` | `413 payload_too_large` | | Empty file, a video under 30 seconds, a 360° video, a photo upload that isn't a zip, a zip with fewer than 20 JPEG or PNG photos (5 with a COLMAP model; a SplatKing video export isn't counted) | `/process` | `422 source_unusable` (`preflightLaunch`, `packages/core/src/preflight.ts`) | | Video too short for 5 frames, zip bomb, unusable COLMAP model | pipeline | scene `failed`, credits refunded | After a `400 source_missing` or `422 source_unusable` the scene stays `uploading`: PUT a usable file to the same upload URL and call `/process` again. ## Video ```bash theme={null} SIZE=$(wc -c < walkthrough.mp4 | tr -d ' ') curl -X POST https://api.splat-3d.com/v1/scenes \ -H "Authorization: Bearer $SPLAT3D_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"content_type\": \"video/mp4\", \"file_size\": $SIZE}" curl -X PUT "$UPLOAD_URL" \ -H "Content-Type: video/mp4" \ --upload-file walkthrough.mp4 curl -X POST "https://api.splat-3d.com/v1/scenes/$SCENE_ID/process" \ -H "Authorization: Bearer $SPLAT3D_API_KEY" ``` * **Containers and codecs.** Create accepts any `video/*` type, e.g. `video/quicktime` for a MOV. The pipeline decodes with ffmpeg, through `sharp-frame-extractor` (`_extract_sharp_frames`); a file it can't decode fails the job. * **Frames.** The sharpest frame in each 0.67 s window is kept and turned upright from the video's rotation metadata (`_extract_sharp_frames`). Frames under half the median sharpness are then dropped, at most 40% of them and never below 24 frames (`_gate_frames_by_sharpness`). * **Length: at least 30 seconds**, or take at least 20 photos instead ([Photo zip](#photo-zip)). `/process` and `/retrain` refuse a shorter video with `422 source_unusable` before charging, `details` `{"field": "video_seconds", "value": 12.4, "allowed": {"min": 30}}` (`MIN_VIDEO_SECONDS`). The length is read from the MP4 or MOV header; a video whose length can't be read that way (WebM, a fragmented MP4 that records no total length, a damaged header) launches, and fewer than 5 frames fails the job (`_extract_video_frames`). The web app also checks the length in the browser, before the upload (`app/upload/page.tsx`). A SplatKing video export's `wide.mov` is not checked. * **No 360° video, yet.** `/process` and `/retrain` refuse a 360° video at any length with `422 source_unusable` before charging: COLMAP has no equirectangular camera model, and the trainer can't render one. That is a video whose frame, as shown (rotation applied), is 2:1 within 1%, `details` `{"field": "video_aspect", "value": 2, "allowed": {"not": 2}}` (`isEquirectAspect`), or one of another shape whose sample entry carries Spherical Video V2 metadata (an `sv3d` box), `{"field": "video_projection", "value": "spherical", "allowed": ["flat"]}`. A flat video at exactly 2:1 is refused too. A 360° video under 30 seconds gets this refusal, not the length one. The web app checks the frame in the browser too. * **800-frame cap.** More than 800 frames are thinned evenly to 800 (`_stage_extract_frames`). At one frame per 0.67 s, that starts at about 9 minutes of video. ## Photo zip ```bash theme={null} zip -r capture.zip photos/ SIZE=$(wc -c < capture.zip | tr -d ' ') curl -X POST https://api.splat-3d.com/v1/scenes \ -H "Authorization: Bearer $SPLAT3D_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"content_type\": \"application/zip\", \"file_size\": $SIZE}" curl -X PUT "$UPLOAD_URL" \ -H "Content-Type: application/zip" \ --upload-file capture.zip ``` Then `POST /v1/scenes/{id}/process` with no body. * **JPEG and PNG only**, by extension: `.jpg`, `.jpeg`, `.png`, any case (`IMAGE_SUFFIXES`). HEIC, HEIF, WebP and every other file are skipped: export them as JPEG first. * **At least 20 photos**, taken all around the subject, or record at least 30 seconds of [video](#video). `/process` and `/retrain` refuse fewer with `422 source_unusable` before charging, `details` `{"field": "photo_count", "value": 12, "allowed": {"min": 20}}` (`MIN_LAUNCH_PHOTOS`), whenever they can read the zip's file list (`screenZipTail`). A [COLMAP model](#colmap-workspace-zip) needs 5, and a [SplatKing video export](#splatking-export) isn't counted. A zip whose file list can't be read (ZIP64) launches; under 5 photos the job then fails at extraction and is refunded (`_extract_photos_zip`). * **At most 400 kept.** A bigger set keeps 400, evenly spaced in path order, the first and last included (`_pick_photos`). * **Folders.** Photos are read at any depth, so a wrapper folder is fine. Folders, empty files, `__MACOSX`, dotfiles, `Thumbs.db`, `desktop.ini` and symlinks are skipped (`_screen_archive_entries`). * **Mixed cameras.** Photos may come from different cameras and sizes: photo sets aren't forced onto one shared camera's intrinsics (`ColmapSiftGlomapBackend`). * **Orientation.** Photos are turned upright from their EXIF orientation (`_apply_exif_orientation`), unless ARKit poses come with them, the zip holds a COLMAP model or loose COLMAP `.bin` files, or the photos are split across two or more top-level folders. * **The zip itself.** Use your computer's built-in zip, `zip`, or 7-Zip's default: members compressed with bzip2 or LZMA fail the job, as does a member that expands more than 100x or a zip that expands more than 10x (`_check_expansion`). More than 50,000 files fails too (`_screen_archive_entries`). ## ARKit capture Send ARKit camera poses in the `/process` body, with a video or a photo zip uploaded as above. ```bash theme={null} # poses.json is {"arkit_poses": [...]}, holding 5 to 1,000 poses shaped like # the one below. curl -X POST "https://api.splat-3d.com/v1/scenes/$SCENE_ID/process" \ -H "Authorization: Bearer $SPLAT3D_API_KEY" \ -H "Content-Type: application/json" \ -d @poses.json ``` One pose: ```json theme={null} { "timestamp": 0.033, "file_path": "frame_000000.jpg", "transform": [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1], "intrinsics": [1674,0,960, 0,1674,720, 0,0,1], "width": 1920, "height": 1440 } ``` * **Poses.** 5 to 1,000 per scene (`processSceneBodySchema`, `api/src/routes/schemas.ts`); the [SfM backends](/guides/sfm-backends#path-a-arkit-poses-via-api-recommended-for-ios) guide has the field table. Two poses with the same `file_path` are `400 invalid_input` (`processScene`). * **What makes it an ARKit job.** With 5 or more poses, the API stores them and sets the pipeline's `has_arkit_poses` flag (`processScene`). The pipeline converts them to a pose sidecar, `arkit_colmap_poses.json` (`_download_and_convert_arkit_poses`). It treats a job as ARKit only when it has both. * **What the poses do.** GLOMAP still solves the camera poses from the images (`_stage_sfm` in `pipeline/training/impl.py`). The ARKit poses set which way is up, and for a video, which frames are cut: up to 150, at the pose timestamps, in the sensor's native orientation (`_extract_arkit_timestamp_frames`). * **Gravity up.** Poses must be in a gravity-aligned world with +Y up: ARKit `worldAlignment` `.gravity` (the default) or `.gravityAndHeading`, never `.camera`. Fewer than 30 frames or photos paired with a pose, or a median tilt residual over 3°, falls back to the camera-based estimate, which lays portrait captures on their side (`MIN_PAIRS`, `MAX_MEDIAN_TILT_DEG`, `pipeline/training/arkit_gravity.py`). * **Pairing.** Video frames pair with poses by video time, allowing a gap of −0.5 to 2.5 s between the video's first frame and the first pose (`up_from_frame_times`). Photos pair by `file_path`, the photo's path in the zip; the file name alone works when it is unique (`up_from_file_names`). Photos are used as stored, with no EXIF rotation, so their pixels must be in the camera axes their `transform` describes. * **A `sparse/` model in the zip is ignored** when ARKit poses are sent (`_extract_photos_zip`). **What the Swift SDK sends.** [Splat3D](/sdks/swift) records an MP4 under ARKit world tracking with the default gravity alignment, creates the scene with `content_type: "video/mp4"` and uploads it as `video/mp4`. Its `/process` body carries poses sampled at about 10 per second while tracking is normal, thinned evenly to 1,000, plus LiDAR points (at most 50,000) on devices that have LiDAR (`SplatScanner`, `ProcessSceneBody` in [splat3d/splat-swift-sdk](https://github.com/splat3d/splat-swift-sdk)). ## COLMAP workspace zip A zip with a COLMAP sparse model skips structure-from-motion: the model is validated, then training starts. Detection is automatic; there is no flag to set (`find_prebuilt_model`, `pipeline/colmap/prebuilt.py`). ```text theme={null} scene.zip ├── images/ │ ├── frame_001.jpg │ └── ... └── sparse/ └── 0/ ├── cameras.bin ├── images.bin └── points3D.bin ``` ```bash theme={null} zip -r scene.zip images sparse ``` Create and upload it as a [photo zip](#photo-zip) (`application/zip`), then call `/process` with no body. * **Layout.** `images/` plus `sparse//` holding all three binaries, or all three text files (`cameras.txt`, `images.txt`, `points3D.txt`), which are converted to binaries first (`convert_text_model`, `pipeline/colmap/text_model.py`); binaries win when a folder has both. A zip whose `sparse/` models all lack a binary fails the job; an incomplete set of text files is ignored, and the photos go through SfM. A single wrapper folder is fine. Among several models, `sparse/0` wins, then the one with `images/` beside its `sparse/`; a remaining tie fails, naming each (`_pick_model`). Photos resolve to the nearest `images/` at or above the model's workspace (`_resolve_images_prefix`). * **Tool outputs that work as zipped:** Inria 3DGS `convert.py` (its undistorted `sparse/0` and `images/`) and nerfstudio `ns-process-data` (`colmap/sparse/0` with the top-level `images/`). * **Validation** (`validate_prebuilt_reconstruction`) fails the job before training, with a message saying what to fix, on: * truncated or corrupt binaries; * a binary over its size cap, checked at extraction: 8 MB `cameras.bin`, 512 MB `images.bin`, 2 GB `points3D.bin` (`MAX_BIN_BYTES`), and the same caps on the matching text files (`MAX_TEXT_BYTES`); * a text file line that doesn't parse, longer than 16 MB, or not UTF-8; * a camera model other than `SIMPLE_PINHOLE`, `PINHOLE`, `SIMPLE_RADIAL`, `RADIAL` or `OPENCV`; * a camera with zero width or height, a focal length that isn't a positive number, or a parameter that isn't finite; * a pose that isn't finite, or an all-zero rotation quaternion; * a sparse point whose position isn't finite; * an image whose camera id isn't in `cameras.bin`; * fewer than 5 or more than 800 registered images; * fewer than 1,000 or more than 5,000,000 sparse points; * a name in `images.bin` with no matching photo, or listed twice; * a photo that isn't its camera's width × height, or a uniform resize of it. * **800 registered images at most, refused rather than thinned** (`MAX_REGISTERED_IMAGES`): dropping an image would orphan its pose. A text model whose points have no tracks (SplatKing) is thinned instead; see below. Photos the model never registered are ignored (`prune_unreferenced_images`). * **Price.** The full price of the preset, like any scene: `/process` prices by preset only (`processScene`). * **5 photos, not 20.** SfM is skipped, so the [photo zip](#photo-zip) minimum doesn't apply: `/process` refuses fewer than 5 photos, and validation needs 5 registered images. * **Loose `.bin` files outside a `sparse/` folder don't count.** That zip runs as a photo zip, and GLOMAP solves the poses (`_extract_plain_photo_zip`). [SfM backends](/guides/sfm-backends#path-b-colmap-zip-upload) has the full requirements, with the COLMAP commands that fix each failure. ## SplatKing export Zips from the SplatKing iOS app upload as they are: create the scene with `content_type: "application/zip"`, PUT the zip, and call `/process` with no body. * **LiDAR capture** (`LidarSeries__