The .img format
A versioned, ZIP-backed project format — the editor's canonical save. What's inside, how migration works, and how to produce one programmatically.
.img is the editor's first-party project format — the equivalent of Photoshop's .psd or Figma's .fig. It's designed to be open, versioned, and machine-friendly so future automation (and AI agents) can produce valid projects without ever opening the editor UI.
#Container
A .img file is a ZIP archive. Open it with any unzip tool to see:
my-project.img/
├── manifest.json // version, format, feature flags, resource manifest
├── document.json // the full document state — layers, groups, history
├── assets/ // user-imported raster sources
├── bitmaps/ // base bitmap layer + decoded bitmaps
├── masks/ // erase / draw / asset-erase masks
├── preview/ // small thumbnail for file previews
└── ... // additional resource folders by kind
Resources are referenced by id from
document.json— the document never inlines large binaries.
#Manifest
manifest.json is the front door. It carries:
- format — always
"img" - formatVersion — currently
1. Bumped only for container changes (folder layout, manifest shape). - schemaVersion — currently
2. Bumped for document model changes — new required fields, renames, removed fields, changed semantics. - legacySchemaVersions —
[1]. Versions that the loader can read via migration. - features — opt-in feature flags (e.g.
groups,text-on-path,frame-clip) so loaders can warn early on docs they don't fully understand. - resources — manifest of every asset / mask / bitmap, with id, kind, mediaType, dimensions, and byte size.
#Document
document.json is the layered project. Top-level shape (simplified):
interface EditorDocument {
schemaVersion: 2
canvas: { width: number; height: number; background: BackgroundLayerData }
crop?: CropState
cropEnabled?: boolean
layers: LayerData[] // all layer kinds, flat list, z-ordered
groups: GroupData[] // required since v2 — empty array if none
history: { past: Snapshot[]; future: Snapshot[]; current: Snapshot }
}
Layer kinds (bitmap, shape, text, icon, asset, draw) all share a base shape (id, transform, opacity, blendMode, effects, optional groupId) plus their kind-specific fields.
#Versioning rules
The project's CLAUDE.md sets the contract — read the schema rules (in-repo: image-tool/CLAUDE.md) for the full text. Summary:
| Change | Action |
|---|---|
| Additive optional field with sane absent default | No version bump. Update normalize / clone / serialize / migration carriers. |
| Additive required, rename, removal, or changed semantics | Bump EDITOR_DOCUMENT_SCHEMA_VERSION. Append previous to legacy list. Add a migrator. |
The migration runner walks from any legacy version to current — multi-step migrations compose automatically.
#Migration mandatory checklist
schema.ts— bumpEDITOR_DOCUMENT_SCHEMA_VERSION, append previous toEDITOR_DOCUMENT_LEGACY_SCHEMA_VERSIONS, update interfaces.migrate.ts— addmigrateVNToVN+1and register it.normalize.ts— updatecloneDocumentState,createEmptyEditorDocumentState, per-field cloners.toDocument.ts/fromDocument.ts— both directions spread the new field through, including history entries.serialize.ts—cloneDocumentForPackagecarries the new field; updatecollectFeatureFlags.- Document the change in
docs/. npm run checkand round-trip a saved.imgfrom the previous version.
#Producing a .img programmatically
You can build a valid project without ever opening the editor. The minimum viable shape:
import JSZip from 'jszip'
const document = {
schemaVersion: 2,
canvas: { width: 1080, height: 1080, background: { type: 'color', color: '#0a0a0a' } },
layers: [
{
id: 'lyr_text_1',
type: 'text',
x: 80, y: 80, width: 920, height: 200, rotation: 0,
text: 'Hello world',
fontFamily: 'Inter', fontSize: 96, fontWeight: 700,
italic: false, align: 'left', color: '#f59e0b',
opacity: 1
}
],
groups: [],
history: { past: [], future: [], current: { /* same shape as document layers/groups */ } }
}
const manifest = {
format: 'img',
formatVersion: 1,
schemaVersion: 2,
legacySchemaVersions: [1],
features: [],
resources: []
}
const zip = new JSZip()
zip.file('manifest.json', JSON.stringify(manifest, null, 2))
zip.file('document.json', JSON.stringify(document, null, 2))
const blob = await zip.generateAsync({ type: 'blob' })
// `blob` is now a valid `.img` you can save / open in the editor
The editor's serialize.ts is the authoritative implementation — read it if you need the exhaustive shape including bitmaps, masks, and history. The structure shown above is the minimum the loader will accept and migrate.
#Why this format
- Open — anyone can unzip and inspect.
- Versioned — schema bumps don't break old files; migrations are explicit.
- Additive — new features default to absent; old files stay valid.
- Machine-friendly — JSON-first, no proprietary binary blobs in the document.
- Compact — ZIP's deflate handles the boilerplate, resources stay in their native binary form.
#See also
- For AI agents — programmatic editing primer
- Layers — the document's layer model
- Groups — group container shape