> ## Documentation Index
> Fetch the complete documentation index at: https://docs.splat-3d.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 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.md).
* **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.md#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.md) 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/<model>/` 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.md#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_<date>_<time>/`). Its
  `COLMAP_Text_Model/` is a COLMAP text model of the app's ARKit poses,
  with the photos in `COLMAP_Text_Model/images/`. It takes the
  [COLMAP workspace](#colmap-workspace-zip) path: SfM is skipped and the
  poses are trained as they are, with the same validation. Photos outside
  `COLMAP_Text_Model/images/` (a rejected frame the app can leave at the
  capture root) are ignored. The photos are stored sideways for a portrait
  capture, so up is not estimated from the cameras: the model's header
  declares a gravity-aligned world with +Y up, and training uses that
  (`declared_world_up`). Training starts from the app's LiDAR point cloud
  in `points3D.txt` (134,977 to 258,496 points in the captures we tested),
  held to the same 1,000 to 5,000,000 range; the depth maps in
  `sensor_data/` are not used. Those points carry no tracks, so a capture
  over 800 frames (about 14 minutes at the app's rate) keeps 800, evenly
  spaced by name (`_cap_text_model`).
* **Video capture** (`Video_<date>_<time>/`). It holds no photos and no
  camera poses, so `wide.mov` (the 1x lens) is processed as a
  [video](#video) and `ultra.mov` is ignored (`_pick_zip_video`). This
  applies to any zip with fewer than 5 photos and one file named
  `wide.mov`; `/process` refuses two such files with `422 source_unusable`.
  With 5 to 19 photos beside a `wide.mov`, the photos are processed, so
  `/process` refuses the zip as too few photos.
  `ultra.mov` doubles the zip's size, so a capture longer than about 3
  minutes passes the 5 GB upload cap: upload its `wide.mov` alone as a
  video instead.

## Related

* [Upload limits](/guides/upload-limits.md): the 5 GB cap and how it is checked.
* [SfM backends](/guides/sfm-backends.md): pose estimation, ARKit poses and COLMAP models.
* [Quickstart](/quickstart.md): the full create, upload, process and download loop.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.