koi.photoDocs

.koi file spec

What's inside a .koi file: the ZIP layout, manifest, Loro document, tile codec and blobs. For converter authors.

This page describes the .koi format as koi.photo writes it today (format version 2; version 2 only added 16-bit tiles). Notes marked "older files" describe what earlier versions wrote, which readers should still accept. It's for developers who want to write a converter or a reader, and for anyone curious about what's in their files. If you just want to save and open documents, read .koi files instead.

The koi.photo source includes a small standalone reader, scripts/koi-inspect.mjs (Node 22 or newer, plus the @zip.js/zip.js and loro-crdt packages). It prints everything described here and decodes pixels to PNG. Every example output on this page comes from running it on a file saved by the editor.

node scripts/koi-inspect.mjs photo.koi out/

Overview

A .koi file is a ZIP archive with four kinds of content:

  1. manifest.json: a small JSON header with the format version, canvas size and a list of every blob.
  2. document.loro: the document itself (layer tree, layer settings, text, vector paths) as a Loro CRDT snapshot. This is binary, not JSON.
  3. blobs/…: pixel tiles and other binary data, one file per blob, named by its SHA-256 hash.
  4. thumbnail.png: a small flattened preview.

It can also hold extensions/…: data other tools stored in the file, which koi.photo keeps when it saves again.

There's no plain-JSON copy of the document and no full-size flattened image in the file. To read layers you need a Loro library. To get a picture without rendering, use thumbnail.png, which is 256 px on its longest side.

Container

PropertyValue
File extension.koi
MIME typeapplication/vnd.photokoi+zip
ContainerZIP. Zip64 may appear in very large files, so use a reader that supports it.
MagicPK\x03\x04 at offset 0, then the entry name mimetype at offset 30 and the MIME type at offset 38
TimestampsDOS timestamps only, no extended-timestamp extra fields

Entries

photo.koi (ZIP)
├── mimetype              STORED   "application/vnd.photokoi+zip", always the first entry
├── document.loro         STORED   Loro snapshot of the document
├── thumbnail.png         STORED   flattened preview, 256 px on the longest side (optional)
├── preview.webp          STORED   reserved, not written today (optional)
├── blobs/<sha256 hex>    STORED   one entry per blob: tiles, fonts, metadata segments
├── extensions/<id>/…     STORED   other tools' data, kept across saves (optional)
└── manifest.json         DEFLATE  written last, because it lists every blob's size

Every entry is STORED (uncompressed) except manifest.json, which is deflated. Tiles are already compressed by their own codec, so the ZIP layer doesn't compress them again. Only the position of mimetype matters: it's the first entry, STORED, with no extra field and no data descriptor, so the MIME type sits at byte offset 38. Other entries use data descriptors and can come in any order, so read them through the central directory.

Here's the entry list of a sample file, as koi-inspect prints it:

== entries
stored        28  mimetype
stored      2915  document.loro
stored      2731  thumbnail.png
stored      2401  blobs/6da9f37c024d6a05d4da66b98cfefe27bdf8d059d8602fc3bf946e0a42c78ee6
stored      1100  blobs/10ccac5a3e44ab750abe9789f134b4e49a6029dce4ebdc9f2400be10966644ba
…
deflate     1410  manifest.json

manifest.json

UTF-8 JSON, one object:

{
  "format": "photokoi",
  "formatVersion": 2,
  "minReaderVersion": 1,
  "appVersion": "0.0.1",
  "docId": "ylcuxC72nB3i0P5H",
  "created": "2026-10-07T17:04:28.190Z",
  "modified": "2026-10-07T17:04:34.746Z",
  "canvas": { "width": 800, "height": 600, "dpi": 72, "colorProfile": "sRGB", "bitDepth": 8 },
  "history": "shallow",
  "blobs": [
    { "hash": "sha256:6da9f37c024d6a05d4da66b98cfefe27bdf8d059d8602fc3bf946e0a42c78ee6", "size": 2401, "kind": "tile" }
  ],
  "extensions": {},
  "missingBlobs": []
}
FieldTypeMeaning
format"photokoi"Constant.
formatVersionintegerFormat version of the writer. Currently 2. The document tree is the same as in version 1; version 2 added 16-bit (rgba16f) tiles.
minReaderVersionintegerOldest reader version that can open the file: 2 when the file holds 16-bit tiles (a 16-bit document, or a 16-bit tile in an 8-bit one), otherwise 1. A version-1 reader refuses 16-bit files instead of misreading them.
appVersionstringkoi.photo version that wrote the file. Informational.
docIdstringStable document id, the same across saves. koi.photo uses it to match the file to an open document or a local working copy.
created, modifiedstringISO 8601 date-times in UTC with a Z suffix.
canvasobjectwidth, height (px), dpi, colorProfile ("sRGB" or "embedded"), bitDepth (8, 16 or 32). A copy of the document settings. When the two disagree, the settings inside document.loro win.
history"shallow" or "full"Whether document.loro carries edit history. koi.photo always writes "shallow".
blobsarrayEvery blob in blobs/: hash (sha256: + 64 lowercase hex), size (bytes) and kind.
blobs[].kindenumtile, original, font, brush or other. A hint, see below.
extensionsobjectExtension id → { "version": string } for the data in extensions/<id>/. Defaults to {}. See Extensions.
missingBlobsstring[]Hashes the document references that weren't available when the file was saved. They aren't in the ZIP. Defaults to [].

blobs[].kind says what a blob is: tile for blobs referenced from tile maps, font for uploaded fonts and other for image metadata segments. original and brush are reserved and not written yet. Don't depend on it: files written before October 2026 list font files as tile, so to find out what a blob is, look at where the document references it. If you read the manifest strictly, treat a kind you don't know as other; koi.photo does.

Compatibility rules

  • A reader must refuse files with minReaderVersion greater than the version it supports. koi.photo says "Created with a newer version of koi.photo".
  • Unknown top-level manifest fields are ignored. koi.photo doesn't keep them: it writes a fresh manifest on every save. Data that should survive a save belongs in extensions/.
  • koi.photo validates the manifest strictly. If you write .koi files, format, the hash pattern, the kind values, the bitDepth values and the Z date-times must be exactly as above, or the whole file is rejected. Releases before October 2026 also reject unknown kind values, so write one of the five.
  • There are no migrations yet: version 2 only added 16-bit tiles, and the document tree is the same as in version 1. New props and layer kinds may be added with defaults, so a reader should fill in defaults for missing props and skip props and kinds it doesn't know.
  • Changes that older readers handle without help don't bump the version. Files from older versions can contain things newer writers leave out: a trash root, settings.blendSpace, font blobs listed as tile, and thumbnails smaller than 256 px. Readers should accept both.

Extensions

Other tools can store their own data in a .koi under extensions/<id>/…, where <id> is a reverse-domain or dotted name such as com.example.tool. Declare the id in manifest.extensions with your data's version.

  • koi.photo keeps these entries: when it opens a file, it stores them with the document, and every later save writes them back unchanged, under the same paths, with the same version. They survive autosave, reopening and cloud sync.
  • If the extensions/ folder of a file holds something different from what the document kept, the file wins.
  • Limits: 16 MB in total per file. Paths must be relative, with no empty, . or .. segments and no backslashes or control characters. Entries over the limit or with invalid paths are left out, and koi.photo tells the user.
  • koi.photo never reads or changes the contents. If a user edits the document, your data may no longer match it, so check it against the document (for example by layer uid) before you trust it.
  • koi.photo also keeps a copy of this data inside document.loro (resources.extensions). That copy is internal: read your data from extensions/.

document.loro

document.loro is a binary snapshot of a Loro document. koi.photo writes it with loro-crdt 1.16 in shallow-snapshot mode at the current version, which means it holds the current state without the edit history. Read it with any Loro 1.x implementation: loro-crdt in JavaScript, or the loro crate in Rust. A shallow snapshot can't be checked out at earlier versions.

import { LoroDoc, LoroMap, LoroText } from "loro-crdt"

const doc = new LoroDoc()
doc.import(await read("document.loro"))
const settings = doc.getMap("settings").toJSON()

The document has four root containers:

ContainerLoro typeContents
treeLoroTreeThe layer tree.
settingsLoroMapCanvas size and other document settings.
metaLoroMapDocument identity.
resourcesLoroMapEmbedded fonts and image metadata.

settings

A key may be missing. If it is, use the default.

KeyTypeDefaultMeaning
width, heightinteger1920, 1080Canvas size in px.
dpinumber72Resolution. Affects unit display and print size only.
unitspx, in, cm, mm, ptpxDisplay unit. Everything in the file is stored in px.
colorProfilestring"sRGB""sRGB", or "embedded" when the pixels are in the ICC profile stored in resources.metadata.icc. Imports keep the source pixel values without colour conversion.
bitDepth8, 16, 328Document depth. In a 16-bit document new pixels are rgba16f tiles; it can still hold 8-bit tiles (from before a conversion), so look at each tile's format byte. 32 is read as 16.
guidesarray[]{ orientation: "horizontal" or "vertical", position }.
gridobject or nullnull{ spacing, subdivisions }.

Files from before October 2026 can also have blendSpace (gamma or linear). It was never used, since the renderer always blends gamma-encoded values. Ignore it.

meta

KeyTypeMeaning
formatVersionintegerTree schema version, the same as in the manifest.
docIdstringThe same as manifest.docId.
createdAtnumberCreation time in Unix milliseconds.
titlestringDocument title.

resources

KeyValue
metadataLoroMap with keys exif, xmp, iptc and icc. Each value is a 32-byte binary SHA-256: the raw metadata segment the imported image carried, stored as a blob. XMP is UTF-8 text.
extensionsInternal copy of the extensions/ entries (id → { version, files }, with sha256: string hashes). Read the ZIP entries instead, see Extensions.
fontsLoroMap from face id (upload: + 16 hex characters) to { family, style, postscriptName, blob, embedding }. blob is a string sha256:… pointing to the font file exactly as the user uploaded it. embedding is one of installable, editable, preview, restricted. Fonts marked restricted aren't embedded.

Only fonts uploaded with Text ▸ Upload Font… are embedded. Text set in Google Fonts or system fonts refers to them by family and style name, and the font files aren't in the .koi.

The layer tree

Each node of the tree LoroTree carries a data LoroMap with these reserved keys:

KeyTypeMeaning
uidstringStable layer id (16 characters). Use this, not the Loro TreeID, which can change.
kindstringNode kind, see below.
vintegerProps schema version for this kind. Currently 1.
tilesLoroMapTile map. Only on pixel and mask nodes.

Every other key in data is a prop. Props are plain JSON values, with one exception: the story of a text layer is a LoroText.

Tree shape:

  • The top level holds artboards. koi.photo uses one artboard, named "Canvas", whose rect matches the canvas. Only the first artboard is rendered.
  • Files written before October 2026 may also hold a hidden top-level node with kind: "trash" and no uid. It contains deleted layers kept for undo, and they can reference blobs that aren't in the file. Skip it. Newer files don't contain it.
  • Children are ordered bottom to top: the first child is the lowest layer. Loro's node.children() returns them in this order.

Here's the sample file's tree as koi-inspect prints it, with some props left out:

artboard pmWs7cmgV07j509z v1           name: "Canvas", rect: {x:0,y:0,width:800,height:600}
  pixel kbixDoBh9OUBFNH5 v1            name: "photo", origin: {x:0,y:0}, format: "rgba8"
      tiles: 0,0=sha256:6da9f37c024d… 0,1=sha256:6da9f37c024d… 1,0=rgba:22aa55ff 1,1=rgba:22aa55ff …
    mask qsbGRCNik0vh789J v1           source: "pixel", emptyValue: 0, density: 1, feather: 0
        tiles: 0,0=sha256:f3b606d61640… 0,1=sha256:8ca7cf54eed5… 1,0=sha256:fd09ede23601… …
  adjustment X2dXmJ3IgGmGK2Bi v1       effect: "core.brightnessContrast", params: {brightness:0.2,contrast:0.1}
  group yDHwt8r2HNd0gMOZ v1            blendMode: "passThrough", expanded: true
    text D4AX8KUDEJ41TT3l v1           story: [{insert:"Hello koi"}], mode: {kind:"artistic"}, transform: [1,0,0,1,60,120]
    shape RRVIJtI3qukLBVbw v1          shape: "core.ellipse", size: {width:160,height:100}, transform: [1,0,0,1,380,240]

Reading a node's props takes a few lines:

for (const key of data.keys()) {
  if (["uid", "kind", "v", "tiles"].includes(key)) continue
  const value = data.get(key)
  props[key] = value instanceof LoroText ? value.toDelta() : value instanceof LoroMap ? value.toJSON() : value
}

What children mean

What a node's children do depends on the node's kind:

ParentChildren
artboard, groupOrdinary layers, composited bottom to top. A mask child masks the whole group.
pixel, vector, shape, textmask children mask the layer. adjustment and liveFilter children apply to this layer only. Any other child is clipped to the layer, like a clipping mask in Photoshop: it's drawn directly above the layer and only where the layer has alpha.
booleanGroupvector, shape and booleanGroup children are the operands of the boolean operation, not layers of their own. Other children are handled like the row above.
adjustment, liveFiltermask children limit where the effect applies. Other content is clipped to the adjustment.
maskNone.

When several masks are present, the topmost visible pixel mask is the one used. An adjustment or liveFilter placed directly in a group or artboard applies to everything below it in that group.

Units, colours and transforms

  • Units: document pixels everywhere. The y axis points down, and the origin is the top-left corner of the canvas.
  • Colours: { r, g, b, a } with channels from 0 to 1, in sRGB, with straight (not premultiplied) alpha.
  • Opacity values (opacity, fillOpacity, density) are 0 to 1.
  • Transforms: [a, b, c, d, tx, ty], mapping layer-local (x, y) to (a·x + c·y + tx, b·x + d·y + ty). That's the same layout as Canvas2D setTransform and SVG matrix(). A positive rotation turns clockwise on screen. Groups have no transform, so every transform maps straight to document space.
  • Pixel layers have no transform. They're positioned by origin (whole pixels), and rotating or scaling one resamples its tiles.

Props shared by all layers

All kinds except artboard have these props:

PropTypeDefaultMeaning
namestringdepends on kindLayer name.
visiblebooleantrue
lockedbooleanfalse
opacity0–11Layer opacity, including layer effects.
fillOpacity0–11Opacity of the content only, without layer effects (Photoshop's "Fill").
blendModeenumnormal (passThrough for groups)See Blend modes.
effectsarray[]Layer effects, see Layer effects.
colorTagenum or nullnullred, orange, yellow, green, blue, violet or gray.
transformOrigin{ x, y } or nullnullRotation centre, 0–1 within the layer's bounds. null means the centre.
blendRangesnullReserved, always null.
exportSettingsnullReserved, always null.
psdobjectabsentPhotoshop data koi.photo can't represent yet, kept as-is from a PSD import.

Node kinds

KindOwn props (in addition to the shared props)
artboardname, visible, locked, rect ({ x, y, width, height }), background (paint or null), clipContent, exportSettings. No shared layer props.
groupexpanded (panel state). A group with any blend mode except passThrough is isolated.
pixelorigin ({ x, y }, the tile grid offset), format (rgba8; rgba16f is reserved), alphaLocked, and the tiles map.
masksource (pixel or vector), emptyValue (mask value where there's no tile: 1 reveals, 0 hides), origin, density, feather (px), invert, enabled, transform and geometry (for vector masks, otherwise null), and the tiles map.
adjustmenteffect (effect id), params (effect-specific JSON), version (1).
liveFilterThe same props as adjustment. Used for filters like blurs and sharpening.
vectortransform, geometry (path), appearance (fills and strokes), pixelAlign.
shapetransform, shape (shape id), params (shape-specific), size ({ width, height }), appearance, pixelAlign.
booleanGroupop (add, subtract, intersect or xor), appearance. No transform.
texttransform, mode, story, defaultCharStyle, defaultParaStyle, appearance.
fill, image, smartObjectDefined in the schema, but the editor doesn't create or render them yet.

Kinds containing a dot (namespace.name) are reserved for plugins. Skip any kind you don't know.

Masks: the mask value m (0 to 1) of a pixel comes from the tile, or from emptyValue where there's no tile. It's then feathered (a Gaussian blur with radius feather), flipped if invert is set (1 − m), and scaled by density: the final value is 1 − density · (1 − m). If enabled is false, the mask is ignored. Vector masks (source: "vector") are stored, but the renderer doesn't use them yet.

Shapes: the path isn't stored, so a converter has to build it. The app generates it from shape, params and size in the local box from (0, 0) to (width, height), and then applies transform. The shape ids are core.rect, core.roundedRect, core.ellipse, core.polygon, core.star, core.triangle, core.line and core.arrow. Their params are defined in src/engine/vector/shapes.ts in the koi.photo source.

Paths (geometry): { subpaths, fillRule }. fillRule is nonzero or evenodd. Each subpath is { nodes, closed }, and each node is { p, in, out, type }. p is the anchor, and in and out are absolute control points in layer-local space, or null for a straight segment on that side. Segments are cubic Béziers from one node's out to the next node's in. type (sharp, smooth, symmetric or smart) only matters for editing.

Appearance: { fills, strokes }. Both are drawn bottom to top, and strokes are drawn above all fills unless a stroke has drawBehindFill. Every entry has id, enabled, opacity and blendMode. A fill entry has a paint. A stroke entry has a stroke object: paint, width, align (center, inside or outside), cap (butt, round or square), join (miter, round or bevel), miterLimit, optional dash ({ pattern, offset }), optional arrowheads, scaleWithObject and drawBehindFill.

Paint: one of these:

  • { kind: "none" }
  • { kind: "solid", color }
  • { kind: "linear" | "radial" | "conical" | "diamond", stops, transform, spread }

stops are { offset, color, midpoint }. spread is pad, repeat or reflect. A gradient's transform maps its unit space into layer-local px. In that unit space, a linear gradient runs from 0 to 1 along x, radial and diamond gradients have radius 1 around the origin, and a conical gradient sweeps clockwise from +x.

Text:

  • mode is either { kind: "artistic" }, where the first baseline sits at local y = 0 and lines break only at newlines, or { kind: "frame", rect, columns, gutter, insets, verticalAlign, overflow }.
  • story is a LoroText, which you read as a delta ([{ insert, attributes }]). Paragraphs are separated by \n. Character styles are marks named c_<key> (for example c_fontSize) and paragraph styles are marks named p_<key> (for example p_align). Each mark overrides defaultCharStyle or defaultParaStyle.
  • Character style keys: fontFamily, fontStyle, fontSize (px), variations, features, tracking (1/1000 em), kerning, leading (px or "auto", which is 1.2 × the font size), baselineShift, horizontalScale, verticalScale, caps, position, underline, strikethrough, fill and language.
  • appearance holds the glyph fills and strokes, like a vector layer's. If the prop is missing, use one black solid fill. A text layer with neither a fill nor a stroke (and no fill in its character style) is invisible; koi.photo warns about it when it opens such a file, and doesn't create one.
  • Glyph outlines aren't stored. To render text, you need the fonts and your own text layout.

Effects

Adjustment and live filter ids (the effect prop):

KindEffect ids
adjustmentcore.brightnessContrast, core.levels, core.curves, core.exposure, core.vibrance, core.hsl, core.colorBalance, core.blackWhite, core.channelMixer, core.selectiveColor, core.whiteBalance, core.lensFilter, core.gradientMap, core.threshold, core.posterize, core.recolor, core.invert, core.lut
liveFiltercore.gaussianBlur, core.boxBlur, core.motionBlur, core.median, core.unsharpMask, core.highPass, core.clarity, core.addNoise, core.dustScratches

params may hold only some of the fields: merge them over the effect's defaults. For example, core.brightnessContrast takes brightness and contrast from −1 to 1, plus legacy (a boolean). koi.photo clamps params to the effect's ranges when they're set and when a file is opened, but files from older versions or other tools can still hold values out of range, so clamp them too. The ranges are defined in src/engine/effects/ in the koi.photo source.

Layer effects

Each entry in a layer's effects array is { type, enabled, blendMode, opacity, …params }. type is one of dropShadow, innerShadow, outerGlow, innerGlow, stroke, bevel, colorOverlay or gradientOverlay.

Blend modes

Blend modes are stored as strings: normal, dissolve, darken, multiply, colorBurn, linearBurn, darkerColor, lighten, screen, colorDodge, linearDodge, lighterColor, overlay, softLight, hardLight, vividLight, linearLight, pinLight, hardMix, difference, exclusion, subtract, divide, hue, saturation, color and luminosity. These are Photoshop's 27 layer modes, in Photoshop's menu order. Groups can also use passThrough. On any other layer, treat passThrough as normal.

Tiles

Raster content (pixel layers and pixel masks) is split into a grid of 256 × 256 px tiles.

  • Tile map: a LoroMap in the node's data.tiles. Its keys are "tx,ty", decimal integers that can be negative.
  • Position: tile tx,ty covers the document pixels from x = origin.x + 256·tx to x = origin.x + 256·tx + 255, and likewise in y. Layers can extend past the canvas. Tiles at the edges are always a full 256², padded with transparent pixels.
  • Values are raw bytes, not strings:
    • 32 bytes are the SHA-256 of a tile blob in blobs/.
    • 4 bytes are a uniform tile, where every pixel has the same RGBA8 value, in R, G, B, A order (koi-inspect prints rgba:22aa55ff). No blob is stored for it. In a mask, all four bytes hold the mask value.
    • No key: in a pixel layer, the tile is fully transparent. In a mask, its value is emptyValue.
  • One resolution only: the file stores full-resolution tiles and nothing else. The renderer builds its downscaled levels in memory.
  • Deduplication: identical tiles share one blob. In the sample, tiles 0,0 and 0,1 point to the same hash.
const tileRef = (b) => (b.length === 4 ? `rgba:${hex(b)}` : `sha256:${hex(b)}`)
const tiles = data.get("tiles") // LoroMap
const tileMap = Object.fromEntries(tiles.entries().map(([key, bytes]) => [key, tileRef(bytes)]))

Pixel format

  • Pixel layers: rgba8, 4 bytes per pixel in R, G, B, A order, rows from top to bottom. Alpha is straight, not premultiplied. Colours are sRGB-encoded, or in the embedded profile when colorProfile is "embedded".
  • 16-bit pixel layers: rgba16f, 8 bytes per pixel: R, G, B, A as IEEE 754 half floats (little-endian), 0 to 1, straight alpha, encoded like rgba8. Any tile of any document may be either format; an 8-bit value v equals the half of v / 255 exactly. Uniform tiles stay 4-byte RGBA8 colours.
  • Masks: r8, 1 byte per pixel, where 0 hides and 255 reveals.

Tile blob encoding (ps-tile v1)

Every blob referenced from a tile map starts with a 16-byte header. All integers are little-endian.

OffsetSizeField
04Magic PSTL (bytes 50 53 54 4C, or 0x4C545350 read as a little-endian uint32)
41Codec version, 1
51Codec: 0 = raw, 1 = shuffle + deflate-raw. Reject anything else.
61Pixel format: 0 = rgba8, 1 = rgba16f, 2 = rgba32f, 3 = r8, 4 = r16f
71Flags. Bit 0 set means straight alpha, which is always the case.
82Width (256)
102Height (256)
124Uncompressed byte length (width × height × bytes per pixel)
16…Payload

An rgba8 tile from the sample starts like this: 50 53 54 4C 01 01 00 01 00 01 00 01 00 00 04 00. That's codec 1, rgba8, straight alpha, 256 × 256, and 262,144 bytes once decoded. An rgba16f tile has 01 at offset 6 and 524,288 bytes decoded; codec 1 then shuffles its 8 byte planes (the low and high byte of each half), and nothing else changes.

Codec 1 works like this:

  1. Split the interleaved pixels into byte planes: all R bytes, then all G bytes, and so on (one plane per byte of the pixel).
  2. Delta-encode each row of each plane: every byte becomes its difference from the byte to its left, mod 256. The first byte of a row is stored as is.
  3. Compress the whole buffer with raw DEFLATE (RFC 1951, no zlib or gzip header).

The writer falls back to codec 0 (the raw pixels) when compression doesn't make the tile smaller. Decoding does the steps in reverse:

const planes = zlib.inflateRawSync(payload) // byte planes, each row delta-coded
const pixels = new Uint8Array(length)
for (let p = 0; p < bpp; p++)
  for (let y = 0; y < h; y++)
    for (let x = 0, prev = 0; x < w; x++) {
      prev = (prev + planes[p * w * h + y * w + x]) & 255
      pixels[(y * w + x) * bpp + p] = prev
    }

In a browser, new DecompressionStream("deflate-raw") does what zlib.inflateRawSync does here.

Blob hashes and names

A blob's hash is the lowercase SHA-256 of the whole blob file: for a tile, that's the header plus the payload. A reader should recompute the hash and treat a blob that doesn't match as missing, which is what koi.photo does. Tiles whose blob is missing render as transparent.

The same hash is written in three forms, depending on where it is:

WhereFormExample
manifest.json (blobs[].hash, missingBlobs), resources.fonts[].blob, resources.extensionsString: sha256: + 64 hex characters"sha256:6da9f37c…"
Tile maps, resources.metadata32 raw bytesUint8Array(32)
ZIP entry names64 hex characters after blobs/, no extensionblobs/6da9f37c…

The binary form keeps document.loro small. The forms won't change, since that would break existing readers.

Other blobs and entries

WhatWhere it's referencedkindBytes
TilesTile maps (32-byte hash)tileps-tile encoded, see above.
Uploaded fontsresources.fonts[id].blob (string hash)font (tile in older files)The font file exactly as uploaded, not tile-encoded.
Image metadata (EXIF, XMP, IPTC, ICC)resources.metadata.<key> (32-byte hash)otherThe raw segment from the imported file.
Placed-image originals, brushesNothing todayoriginal, brushReserved.

thumbnail.png is an RGBA PNG of the flattened canvas. Transparent areas stay transparent. It's resampled with a high-quality filter to exactly 256 px on its longest side, keeping the aspect ratio (rounded to whole pixels), so an 800 × 600 document gets a 256 × 192 thumbnail. Documents smaller than 256 px are kept at full size, not enlarged. The thumbnail is optional; .koi files downloaded from the cloud Files window have one when the cloud has a thumbnail for the document. Files from older versions may have smaller thumbnails (scaled down by powers of two).

preview.webp is reserved. koi.photo accepts it when reading a file, but doesn't write it. Don't rely on it.

extensions/ holds other tools' data, see Extensions.

What isn't stored

  • Undo history. The snapshot is shallow, so a reopened file starts with an empty history.
  • Selections, zoom, the active tool, panel layout and other view state.
  • A full-size composite. The only flattened image is the thumbnail.
  • Rendered vector and text output. Shape paths, boolean results, text glyphs and layer effects are computed when the document is drawn. There's no cached copy for converters: they need their own renderer, or they can fall back to the thumbnail.
  • Fonts that weren't uploaded. Text set in Google Fonts or system fonts refers to them by name only.
  • Deleted layers. Files from older versions can still have them in a trash node, without their tiles.

Reading a .koi into an RGBA image

  1. Open the ZIP and check that the mimetype entry equals application/vnd.photokoi+zip.
  2. Parse manifest.json. Stop if minReaderVersion is greater than the version you support (2 if you decode rgba16f tiles, else 1).
  3. If a small preview is enough, decode thumbnail.png and you're done.
  4. Load the blobs listed in manifest.blobs, and check each one's SHA-256 against its name.
  5. Import document.loro with loro-crdt, and read settings.width and settings.height.
  6. Find the first top-level node with kind: "artboard", skipping trash.
  7. Walk its children bottom to top. For each visible pixel layer, assemble its tiles at origin:
for (const [key, ref] of Object.entries(tileMap)) {
  const [tx, ty] = key.split(",").map(Number)
  const px = ref.startsWith("rgba:") ? uniform(ref) : decodeTile(blobs.get(ref)).pixels // rgba16f: halves, convert as needed
  // copy the 256×256 block to (origin.x + tx * 256, origin.y + ty * 256), clipped to the canvas
}
  1. Apply the layer's mask, if it has one, to its alpha.
  2. Composite. Premultiply, multiply by opacity × fillOpacity, and blend onto the result with the layer's blend mode. Use the W3C Compositing formulas, on gamma-encoded sRGB values. Treat a group as a separate layer that you composite first, unless its blend mode is passThrough.
  3. Decide about the other kinds. Adjustments, live filters, layer effects, vectors, shapes and text need their own renderer. A simple converter can skip them and warn, or fall back to the thumbnail.

koi-inspect does steps 1 to 7 for the first pixel layer and writes layer.png. It also decodes one tile to tile.png (16-bit tiles are rounded to 8-bit for these PNGs). Along the way it checks each blob's hash and kind against where the document references it, lists extensions/ entries and flags invalid paths, reports unknown entries and the thumbnail size, warns about invisible text, and marks what older writers left in a file (a trash root, blendSpace, fonts listed as tile) with note:.

Pitfalls

  • document.loro isn't JSON. It's a binary Loro snapshot, and there's no JSON copy in the file. Use a Loro 1.x library.
  • Hashes are stored in three forms. The manifest and font resources use the string sha256:<hex>. Tile maps and metadata use 32 raw bytes. ZIP entry names use bare hex. See Blob hashes and names.
  • The trash root (in older files) contains deleted layers whose blobs are missing. Skip it.
  • The children order is bottom to top, the reverse of how the Layers panel lists layers.
  • Tile alpha is straight, so premultiply before you blend or filter.
  • No key in a tile map means transparent for pixel layers, but emptyValue for masks. A uniform mask tile repeats the mask value in all four bytes.
  • The manifest kind is a hint. Older files list fonts as tile. Go by where the document references a blob.
  • Adjustment params can be partial, and out of range in older files. Merge them over the effect's defaults and clamp them.
  • Props can be missing in files from older versions. Use the defaults on this page.
  • Use uid as the layer id, not the Loro TreeID.
  • If you write .koi files: put mimetype first, STORED, with no extra field. List every blob in the manifest with its exact size and one of the five kind values. Keep any extensions/ entries that aren't yours. Use Z date-times. Store tile hashes as 32 raw bytes. Give every node a uid, a kind and v: 1, plus a tiles LoroMap for pixel and mask nodes. Generate a fresh docId: koi.photo treats two files with the same docId as the same document.
  • .koi files: saving, opening and sharing .koi files.
  • PSD files: the layered format to use when another app needs to open your work.
  • Export: flattened PNG, JPEG, WebP, SVG and PDF output.
  • Layers & masks: what the layer kinds above look like in the editor.

On this page