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.
On this page (11)
- Clip JSON (*.clip.json)
- Optional additive blocks
- Animation project (*.animproj.json)
- The cast (characters)
- Editor project (*.editorproj.json)
- Map document (*.mapproj.json)
- WorldPlan v1 (procedural seeding input)
- Studio preset (*.preset.json)
- Character set (set.json)
- Source bundle manifest (bundle-manifest.json)
- GLB export
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 — plusset.jsonandbundle-manifest.json— each carryformat+version. A reader must reject aversiongreater 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.
{
"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 ismixamorig:Hips.position. - On import, bone names match exactly first, then with colons stripped (
mixamorigHips≡mixamorig: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#
| Field | Written by | Meaning |
|---|---|---|
tracks[i].interpolations | Curve editor | One 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" tool | The 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.
{
"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.
"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
charactersblock 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 anitemsarray, more than 64 items, or an item without non-emptyid,name, andsetIdstrings. - 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
setIdis 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.
{
"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:
sourcesusually 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.editsentries are discriminated bykind:"transform"(local TRS per node),"material"(fullMeshStandardMaterialstate + 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).primitivesrebuild geometry from a pure parametricspec(box | cylinder | cone | sphere | plane | lathe | torus | icosphere) — no embedded bytes.modifiersare 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.metadatais the document-level catalog object the convention linter reads (category, optional finercategoryFine, and asnake_case_NNid).
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.
{
"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:
assetRefis either{ source: "site", slug }(a catalog asset) or{ source: "local", projectId }(your own saved project). A placement with an unrecognized ref or a non-numericx/zis dropped on load.ry(yaw, radians) ands(uniform scale) are optional, defaulting to0and1; writers omit them at their defaults. An optionalyheight 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.
roadsis optional.moduleis the road-cell size in metres;cellsstores integer grid coordinates, family, and an optional modifier;stampsstores 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:
ypositions 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.json | Notes |
|---|---|---|
x, z (metres) | x, z | Copied verbatim — not grid-snapped (snap is a separate step). |
rotation (radians) | ry | Omitted when 0. |
assetHint / kind | assetRef | A semantic hint, not necessarily a catalog slug — matched to an owned or free asset. |
id | id | Carried 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.
{
"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.
{
"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.
versionis the schema — bump it only for a non-additive manifest change; additive fields never bump it.setVersionis 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 samesetVersion. format+versionare the discriminator. Anotherformat, a missing or non-integerversion, or aversiongreater than 1 is rejected outright rather than misread as v1.idis required and non-empty.- Everything else defaults, so
{ format, version, id }alone is a valid manifest.namefalls back toid,blurbto"",setVersionto"0.0.0",engine.minto1,rigto"mixamo-65", every capability tofalse,content/defaults/proportionSchemato empty,visibilityto"public", and everyaccessflag tofalsewithincludedInPacks: []. A flag is only true for a literaltrue— feature-detect throughcapabilities, where a missing flag means no. engine.minis 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.rigaccepts 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
contentare kept (they are just paths); unknown keys insidecapabilitiesare dropped. - A malformed entry degrades; it never fails the manifest. A
proportionSchemarow survives only with finitemin < maxand a finite positivestep(labeldefaults to the map key). Acontentvalue must be a relative, in-directory path — absolute,..-traversing, and URL-scheme paths are dropped. A whole malformed block degrades to its default. accessandvisibilityare 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.
{
"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:
contentHashexcludesbuildDateby construction. It is computed over the sortedpath+sha256of the entries only, so two builds of the same inputs agree on the hash even though their clocks disagree. ComparecontentHashto tell "the same bundle" from "a genuinely different one".entriesis 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 bumpsversion. formatis 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.glbwith no body mesh. Channels target themixamorigbone nodes, so the file plays in stockTHREE.GLTFLoaderand 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.
Ask us directly — we answer support mail ourselves.