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. Thecontent_type you send to POST /v1/scenes decides how
the file is read.
Which input should I use?
Rules for every input
content_type:video/*,application/ziporapplication/x-zip-compressed. Anything else is400 invalid_input, exceptapplication/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. Leavecontent_typeout and the scene is read as a video (createSceneinapi/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/processreplaces 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. - 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.
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
- Containers and codecs. Create accepts any
video/*type, e.g.video/quicktimefor a MOV. The pipeline decodes with ffmpeg, throughsharp-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).
/processand/retrainrefuse a shorter video with422 source_unusablebefore 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’swide.movis not checked. - No 360° video, yet.
/processand/retrainrefuse a 360° video at any length with422 source_unusablebefore 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 (ansv3dbox),{"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
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.
/processand/retrainrefuse fewer with422 source_unusablebefore 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 needs 5, and a SplatKing video 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.iniand 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.binfiles, 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.
- Poses. 5 to 1,000 per scene (
processSceneBodySchema,api/src/routes/schemas.ts); the SfM backends guide has the field table. Two poses with the samefile_pathare400 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_posesflag (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_sfminpipeline/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 byfile_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 theirtransformdescribes. - A
sparse/model in the zip is ignored when ARKit poses are sent (_extract_photos_zip).
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).
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).
application/zip), then
call /process with no body.
- Layout.
images/plussparse/<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 whosesparse/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/0wins, then the one withimages/beside itssparse/; a remaining tie fails, naming each (_pick_model). Photos resolve to the nearestimages/at or above the model’s workspace (_resolve_images_prefix). - Tool outputs that work as zipped: Inria 3DGS
convert.py(its undistortedsparse/0andimages/) and nerfstudions-process-data(colmap/sparse/0with the top-levelimages/). - 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 MBimages.bin, 2 GBpoints3D.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,RADIALorOPENCV; - 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.binwith 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:
/processprices by preset only (processScene). - 5 photos, not 20. SfM is skipped, so the photo zip
minimum doesn’t apply:
/processrefuses fewer than 5 photos, and validation needs 5 registered images. - Loose
.binfiles outside asparse/folder don’t count. That zip runs as a photo zip, and GLOMAP solves the poses (_extract_plain_photo_zip).
SplatKing export
Zips from the SplatKing iOS app upload as they are: create the scene withcontent_type: "application/zip", PUT the zip, and call /process with no
body.
- LiDAR capture (
LidarSeries_<date>_<time>/). ItsCOLMAP_Text_Model/is a COLMAP text model of the app’s ARKit poses, with the photos inCOLMAP_Text_Model/images/. It takes the COLMAP workspace path: SfM is skipped and the poses are trained as they are, with the same validation. Photos outsideCOLMAP_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 inpoints3D.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 insensor_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, sowide.mov(the 1x lens) is processed as a video andultra.movis ignored (_pick_zip_video). This applies to any zip with fewer than 5 photos and one file namedwide.mov;/processrefuses two such files with422 source_unusable. With 5 to 19 photos beside awide.mov, the photos are processed, so/processrefuses the zip as too few photos.ultra.movdoubles the zip’s size, so a capture longer than about 3 minutes passes the 5 GB upload cap: upload itswide.movalone as a video instead.
Related
- Upload limits: the 5 GB cap and how it is checked.
- SfM backends: pose estimation, ARKit poses and COLMAP models.
- Quickstart: the full create, upload, process and download loop.