Our lifetime deal increases from $97 to $127 in03 days19 hours06 min05 secGet access for just $97 NOW
Studio

Studio file formats

Reference for the editable and exported formats used by Studio: clip JSON, project files, map documents, road networks, presets, WorldPlans, character sets, source bundles, and GLB.

AP
Aron Prins
Founder · Updated Aug 11, 2026 · 15 min read
On this page (11)

Studio persists your work as a small set of documented JSON (and GLB) formats. Two rules hold across all of them:

  • Additive and stable. Once a field or a clip-JSON shape ships, its meaning never changes. New fields are optional and ignorable; a reader that doesn't understand one still loads the file.
  • Versioned files reject the future. The *.animproj.json, *.mapproj.json, *.editorproj.json, and preset envelopes — plus set.json and bundle-manifest.json — each carry format + version. A reader must reject a version greater than it supports and default every missing optional field.

Studio GLBs use standard glTF. Files exported without Draco load in a stock THREE.GLTFLoader; a Draco-compressed Editor export additionally needs DRACOLoader configured with decoder files you host. The JSON formats below are editable project or data formats and should be reopened with the corresponding Studio command, not GLTFLoader.

Clip JSON (*.clip.json)#

The Character editor's Export Clip JSON / Import Clip JSON format. It is exactly the serialized form of a THREE.AnimationClip (AnimationClip.toJSON(clip)), so any three.js mixer can play it directly.

jsonc
{
  "name": "Walk cycle",
  "duration": 1.0,                          // seconds; >= last key time
  "tracks": [
    { "name": "mixamorig:Hips.quaternion",  // <boneName>.quaternion
      "type": "quaternion",
      "times":  [0, 0.5, 1.0],              // ascending, seconds
      "values": [/* 4 floats per key */] },
    { "name": "mixamorig:Hips.position",    // Hips only (root motion)
      "type": "vector",
      "times":  [0, 1.0],
      "values": [/* 3 floats per key */] }
  ]
}

Contract highlights:

  • Track names use the 65-bone Mixamo rig, prefixed mixamorig:. Rotation tracks are <Bone>.quaternion; the only position track authored is mixamorig:Hips.position.
  • On import, bone names match exactly first, then with colons stripped (mixamorigHipsmixamorig:Hips) to tolerate sanitized exports. Tracks for unknown bones are skipped silently.
  • Optional top-level fps (default 30) is honored on import as editor metadata.

Optional additive blocks#

FieldWritten byMeaning
tracks[i].interpolationsCurve editorOne entry per key, describing the segment leaving that key: an easing name (linear, step, ease-in, ease-out, ease-in-out) or a custom cubic { "type": "cubic", "handles": [x1,y1,x2,y2] }. Warps the interpolation alpha only — consumers may ignore it and still get valid playback.
rootMotion"Extract root motion" toolThe horizontal Hips travel removed to make a clip in-place: { times, values } in world-space metres (y always 0). Add it back to reproduce the traveling animation.

Studio's own playback and its GLB export bake non-linear easing to sampled keys at the clip's fps, so exported .glb files play identically everywhere without reading these fields.

Animation project (*.animproj.json)#

The Character editor's Save / Open Project commands and local browser autosave use this versioned schema (version: 1). Unlike clip JSON, clips[i] stores rotation and position key maps rather than AnimationClip tracks.

jsonc
{
  "format": "animproj",
  "version": 1,
  "savedAt": "2026-07-01T12:34:56.789Z",
  "clips": [
    { "name": "Wave", "duration": 2.0, "fps": 30,
      "rot": { "mixamorig:RightArm": [ { "t": 0.0, "q": [x,y,z,w] } ] },
      "pos": { "mixamorig:Hips":    [ { "t": 0.0, "v": [x,y,z] } ] } }
  ],
  "curIndex": 0,
  "playhead": 0.0,
  "settings": { "loop": true, "skeletonVisible": true }
}

Optional additive fields include per-clip id (stable UUID), libraryFile, rootMotion, and a top-level extras block carrying the sequence step list (crossfades between clips) and additive layer stacks keyed by base-clip id. Steps or layers whose clip id doesn't resolve are shown as "(missing clip)" and skipped.

The cast (characters)#

The same version-1 file also stores the project's characters as one more optional top-level block. You build a character in order to animate it, so it is one document — no new project kind and no version bump.

jsonc
"characters": {
  "active": "c1",                          // id of the character in the viewport; optional
  "items": [
    { "id": "c1",                          // stable identity; what `active` references
      "name": "Hero A",                    // unique within the file
      "setId": "character-model-basic",    // the character set it was built from
      "setVersion": "1.0.0",               // content version it was authored against
      "styleId": "hero",
      "style": { "headW": 9.1 },           // SPARSE proportion overrides
      "paletteId": "steve",
      "palette": { "shirt": "#12a150" },   // SPARSE colour overrides, #rrggbb
      "face": { "expression": "happy", "eyeShape": "normal",
                "overrides": { "browArch": 0.5 } } }
  ]
}
  • A malformed characters block is dropped whole and never touches the clips. It is read outside the strict project validation, so a corrupted cast costs you the cast, not the animation. "Malformed" means structural: not an object with an items array, more than 64 items, or an item without non-empty id, name, and setId strings.
  • Overrides are sparse, not a snapshot. Only values that differ from the chosen style and palette are stored, so a character inherits later set-content improvements instead of freezing a copy of them. Junk inside a valid item is dropped field by field.
  • An unresolvable setId is kept, shown as "product unavailable" — a reader never deletes work it cannot render.
  • A project with no characters omits the block entirely and is byte-for-byte what the same document was before the block existed.

Editor project (*.editorproj.json)#

The model Editor's document (version: 1) — the scene of imported and created models, autosaved through the workspace layer.

jsonc
{
  "format": "editorproj",
  "version": 1,
  "nodes":      [ /* the outliner tree: id, name, kind, visible, children */ ],
  "sources":    [ /* imported GLBs, base64 "data" (embedded) or "url" (re-linked) */ ],
  "primitives": [ /* parametric primitive specifications */ ],
  "modifiers":  [ /* per-node modifier stacks */ ],
  "edits":      [ /* per-node transform / material / paint edits */ ],
  "metadata":   { "category": "props", "id": "crate_01" }
}

Key contracts:

  • sources usually embed each imported GLB as base64 so a re-open is self-contained (the 50 MB import cap is enforced before embedding). A re-linked URL must be HTTPS, same-origin, no larger than 50 MB, and must not redirect; otherwise it is rejected.
  • edits entries are discriminated by kind: "transform" (local TRS per node), "material" (full MeshStandardMaterial state + optional texture-slot overrides, sRGB hex colors), and "paint" (per-face palette keys, not raw hex, so re-tinting a key recolors every painted face).
  • primitives rebuild geometry from a pure parametric spec (box | cylinder | cone | sphere | plane | lathe | torus | icosphere) — no embedded bytes.
  • modifiers are a non-destructive stack (shading | weld | decimate | fitBounds); "Apply stack (bake)" freezes the preview into geometry that lives in the viewport, not in the file.
  • metadata is the document-level catalog object the convention linter reads (category, optional finer categoryFine, and a snake_case_NN id).

Populating primitives / modifiers / edits / metadata / palette never bumps version — a v1 reader that ignores them still loads the document. See Optimizing & exporting GLBs.

Map document (*.mapproj.json)#

The Map builder's document (version: 1) — a grid layout of library assets on the 4 m module. Placement coordinates are in metres of world space, not grid cells.

jsonc
{
  "format": "mapproj",
  "version": 1,
  "name": "Harbour Town",
  "world": { "w": 64, "h": 64 },
  "env":   { "tod": "day", "sunElev": 52, "ambient": 0.45, "fogDensity": 0.12,
             "ground": "grassland", "grid": true, "shadows": true, "sky": true },
  "grid":  { "module": 4 },
  "layers": [ { "id": "L1", "name": "Buildings" } ],
  "placements": [
    { "assetRef": { "source": "site", "slug": "home_cottage_01" }, "x": 0, "z": 0 },
    { "assetRef": { "source": "site", "slug": "well_01" },
      "x": 8, "z": 4, "ry": 1.5707963, "s": 1.05 }
  ],
  "roads": {
    "module": 4,
    "cells": [
      { "x": 0, "z": 0, "family": "street" },
      { "x": 1, "z": 0, "family": "street", "mod": "crosswalk" }
    ],
    "stamps": [ { "a": "roundabout", "x": 2, "z": 0, "ry": 0 } ]
  }
}

Contract highlights:

  • assetRef is either { source: "site", slug } (a catalog asset) or { source: "local", projectId } (your own saved project). A placement with an unrecognized ref or a non-numeric x/z is dropped on load.
  • ry (yaw, radians) and s (uniform scale) are optional, defaulting to 0 and 1; writers omit them at their defaults. An optional y height offset lifts a placement off the ground.
  • Environment, snap prefs, world extent, and layers are all optional and defaulted — a document carrying none of them serializes to the minimal placement shape.
  • roads is optional. module is the road-cell size in metres; cells stores integer grid coordinates, family, and an optional modifier; stamps stores standalone road details. The Studio also writes the resolved road tiles as ordinary placements on a dedicated Roads layer so a basic map reader can still display them.
  • Ground-seating gotcha for consumers: y positions the asset's ground-seated base, not necessarily its raw model origin. Many store assets and imported models are centre-origin, while some authored exports already use a grounded pivot. Seat each asset from its bounding box before applying the placement offset instead of assuming every GLB has the same origin.

The Map builder's Export → Map data JSON writes this same placement manifest; Export → GLB bakes the instanced assets into named container nodes. See Exporting a map.

WorldPlan v1 (procedural seeding input)#

A WorldPlan is a generated settlement layout that New from WorldPlan… can turn into a map. Each WorldPlan placement carries { id, kind, assetHint, x, z, rotation, footprint, surface, … } in world-space metres and maps to a .mapproj.json placement:

WorldPlan.mapproj.jsonNotes
x, z (metres)x, zCopied verbatim — not grid-snapped (snap is a separate step).
rotation (radians)ryOmitted when 0.
assetHint / kindassetRefA semantic hint, not necessarily a catalog slug — matched to an owned or free asset.
ididCarried through as placement identity.

The hints ("cottage", "tree", "bench", "street_light", …) are semantic. The Map builder first tries an exact normalized slug, then a keyword match against the slugs, names, and categories of assets you can use. Unmatched placements are skipped, and a plan whose schemaVersion is not 1 is rejected. See Seeding from a WorldPlan.

Studio preset (*.preset.json)#

Named customer presets share one generic, versioned envelope (version: 1). kind selects the owning tool's payload schema — pose, material, mapenv, brush, or character.

jsonc
{
  "format": "threejsassets-studio-preset",
  "version": 1,
  "kind": "pose",
  "name": "Ready stance",
  "payload": {
    "version": 1,
    "bones": {
      "mixamorig:Hips":  { "quaternion": [0,0,0,1], "position": [0,0.94,0] },
      "mixamorig:Spine": { "quaternion": [0.02,0,0,0.9998] }
    }
  }
}

The pose payload captures local quaternions ([x,y,z,w]) for known Mixamo bones plus the Hips local position (metres). Local record ids and timestamps are deliberately omitted, so importing a file creates a fresh local preset without collisions. A reader must reject a future top-level version rather than misapply it, and must not interpret one kind as another. Saved presets are a customer perk.

A kind: "character" payload is one character's appearance on its own: exactly the characters.items[] fields above minus id and name, which belong to the project the character lived in, not to the look. That is why importing one into a project that already holds a "Hero" cannot collide with it.

character is also the one kind that rejects rather than degrades. payload.version must be 1 and payload.setId must be a non-empty string; either one wrong and the whole file is refused before anything is built. Everything else inside the payload degrades exactly like the project cast — unreadable fields are dropped, the preset is not. Applying a preset to a character built from the same set repaints it in place; from a different set it becomes a new character, because the appearance describes a whole build.

Character set (set.json)#

A character set is the content unit of a character product: one manifest plus the sibling data tables it names. The Character Creator reads the manifest — there is no per-product code path — so what a product supports is discoverable from this one file.

jsonc
{
  "format": "character-set",       // required discriminator
  "version": 1,                    // SCHEMA version (integer)
  "id": "character-model-basic",   // stable set id; matches the product slug
  "name": "Basic Character",
  "blurb": "Minecraft-style toon character with a full 65-bone Mixamo rig.",
  "setVersion": "1.0.0",           // CONTENT version (semver) — not the schema version
  "engine": { "min": 1 },          // minimum character-engine version this set requires
  "rig": "mixamo-65",              // the only rig value v1 accepts
  "thumbnailUrl": "/thumbnails/character-model-basic.png",
  "license": "threejsassets-standard-v1",

  "capabilities": { "proportions": true, "palettes": true, "face": true,
                    "outline": true, "toon": true, "hair": true },

  "content": { "styles": "styles.json", "palettes": "palettes.json",
               "faces": "faces.json", "expressions": "expressions.json",
               "visemes": "visemes.json" },

  "defaults": { "styleId": "hero", "paletteId": "steve", "expression": "neutral" },

  "proportionSchema": {            // which style fields are user-editable, and their bounds
    "headW":        { "label": "Head width", "min": 5, "max": 14, "step": 0.1, "group": "Head" },
    "targetHeight": { "label": "Height", "min": 1.2, "max": 2.4, "step": 0.01,
                      "unit": "m", "group": "Overall" }
  },

  "access": {                      // MIRRORED from the site, for display only
    "individual": { "enabled": true, "productSlug": "character-model-basic",
                    "priceCents": 2900 },
    "lifetimeIncluded": false,
    "includedInPacks": [],
    "freeTier": false,
    "previewable": true
  },
  "visibility": "public"           // 'public' | 'hidden'
}

Contract highlights:

  • Two version numbers, never confused. version is the schema — bump it only for a non-additive manifest change; additive fields never bump it. setVersion is the content semver: patch = cosmetic, minor = additive (new styles, palettes, expressions, editable fields), major = an existing style's geometry changed or content was removed. A published set file is never edited in place at the same setVersion.
  • format + version are the discriminator. Another format, a missing or non-integer version, or a version greater than 1 is rejected outright rather than misread as v1. id is required and non-empty.
  • Everything else defaults, so { format, version, id } alone is a valid manifest. name falls back to id, blurb to "", setVersion to "0.0.0", engine.min to 1, rig to "mixamo-65", every capability to false, content / defaults / proportionSchema to empty, visibility to "public", and every access flag to false with includedInPacks: []. A flag is only true for a literal true — feature-detect through capabilities, where a missing flag means no.
  • engine.min is checked against the engine version the Studio ships. A set that needs a newer engine is rejected with the required version named, which is what lets already-published sets survive engine changes.
  • rig accepts one value, "mixamo-65" — the same 65-bone rig clip JSON targets.
  • Unknown top-level keys survive. They are ignored for reading but preserved and re-emitted on serialize, so a v2 manifest round-trips through a v1 reader unchanged. Unknown keys inside content are kept (they are just paths); unknown keys inside capabilities are dropped.
  • A malformed entry degrades; it never fails the manifest. A proportionSchema row survives only with finite min < max and a finite positive step (label defaults to the map key). A content value must be a relative, in-directory path — absolute, ..-traversing, and URL-scheme paths are dropped. A whole malformed block degrades to its default.
  • access and visibility are display mirrors, never a gate. They exist so the UI can show what a product costs and whether it is listed. Entitlement is decided by the server refusing to deliver, never by these fields.

Source bundle manifest (bundle-manifest.json)#

Every character source bundle — the zip an entitled buyer downloads — carries a bundle-manifest.json at its root describing exactly what the archive contains. Use it to verify a download and to record which set version you integrated.

jsonc
{
  "format": "threejsassets.source-bundle",  // required discriminator
  "version": 1,                             // SCHEMA version (integer)
  "setId": "character-model-basic",         // the set this bundle was built from
  "setVersion": "1.0.0",                    // the set's CONTENT semver at build time
  "engineVersion": 1,                       // engine version the bundled engine satisfies
  "rig": "mixamo-65",
  "generator": "threejsassets charbundle",  // tool identity, informational
  "buildDate": "2026-08-08T12:00:00Z",      // ISO-8601, second precision
  "three": "0.180.0",                       // the three.js version the bundle is built against
  "models": [                               // the pre-exported GLBs in models/
    { "file": "character-model-basic-hero.glb", "bytes": 123456,
      "triangles": 4321, "draco": true }
  ],
  "contentHash": "…64 hex chars…",          // sha256 over every entry's path + digest, path-sorted
  "entries": [                              // every file in the zip EXCEPT this manifest
    { "path": "src/set.ts", "bytes": 2048, "sha256": "…64 hex chars…" }
  ]
}

Contract highlights:

  • contentHash excludes buildDate by construction. It is computed over the sorted path + sha256 of the entries only, so two builds of the same inputs agree on the hash even though their clocks disagree. Compare contentHash to tell "the same bundle" from "a genuinely different one".
  • entries is exhaustive and sorted by path, each with its byte length and SHA-256. The manifest itself is the one file not listed — it cannot contain its own hash.
  • Additive evolution only at version: 1. New top-level or per-entry fields may appear; ignore what you don't recognize. Renaming or re-typing an existing field bumps version.
  • format is the discriminator — reject anything else before reading further.

GLB export#

When you want a baked, portable result rather than editable JSON, every tool can export GLB:

  • Character — the full 65-bone rig plus selected clips, or the bare animation_rig.glb with no body mesh. Channels target the mixamorig bone nodes, so the file plays in stock THREE.GLTFLoader and re-imports into Studio.
  • Editor — a plain self-contained GLB of the edited scene, optionally passed through a browser-side glTF-Transform prune / dedup / weld / quantize / Draco pass. Skinned assets skip weld + prune to protect the rig; Draco replaces quantize when both are requested.
  • Map — the placed instances baked to named container nodes.

For a Draco Editor export, configure DRACOLoader with locally hosted decoder files as shown in Optimizing & exporting GLBs. Editor export does not create KTX2 textures. For the underlying asset conventions — Y-up, real-world scale, and supported compression — see the asset format specification.

Still stuck?

Ask us directly — we answer support mail ourselves.

Contact support
Share
Studio file formats — threejsassets.com