Version 2.0.0 Open specification

This page is the canonical specification. It's rendered from landing/docs/dfp-format.md in the source tree byte-for-byte. Implementers can build a reader or writer against it without DepthField Photo — the format is a plain ZIP of JSON and lossless rasters, nothing proprietary or encrypted.

The DepthField Photo project format (.dfp)

This document describes the on-disk format of DepthField Photo project files
(.dfp). The format is open and documented — this file is the canonical
specification. Anyone can read, write, or inspect a .dfp file with standard
tools; there is nothing proprietary or encrypted in the container.

Status: Format version 2.0.1 (2026-10-04). This document is the
canonical specification of the .dfp container. It is implementation-independent
— you can read and write conformant files with standard tools, without
DepthField Photo. The 2.0.0 change from 1.x is tile-backed layer storage; see
§1.2 and §2 for the details and version-check policy. 2.0.1 adds the
colorSpace manifest field (§3, §7).


1. At a glance

A .dfp file is a plain ZIP archive (no encryption). Unzip it with any
standard tool and you'll find a small JSON manifest, one PNG per tile of each
layer's pixels and mask, and one raster image per object mask and saved
selection:

my-project.dfp                  (ZIP archive)
├── manifest.json               document metadata, canvas, layer order + per-layer metadata
├── thumbnail.png               small in-app preview, ≤256 px on the long edge
├── preview.png                 faithful flattened composite, ≤1024 px on the long edge (optional)
├── palette.json                document color palette (optional)
├── exif.json                   EXIF metadata carried over from the source photo (optional)
├── tiles/
│   └── <tileId>.png            one 256×256 (edge tiles smaller) PNG per tile — see §1.2
├── layers/
│   ├── <layerId>.{png|webp}                 a layer's WHOLE pixel data (format 1.x, or a
│   │                                        layer the writer did not tile — see §1.2)
│   ├── <layerId>-mask.{png|webp}            that layer's whole mask (same)
│   └── <layerId>-objmask-<maskId>.{png|webp}  object masks (optional, 0..n per layer)
└── channels/
    └── <channelId>.{png|webp}  a saved selection (alpha channel), one per channel (optional)

Every visual asset is stored losslessly — as either PNG or **lossless
WebP (see §1.1). Everything structural is stored as UTF-8 JSON**. There is no
custom binary encoding to reverse-engineer.

Design rationale:

  • ZIP so the file is inspectable with ubiquitous tools and streams/compresses

well.

  • One lossless raster per asset so each layer round-trips with full alpha and

zero quality loss, and so a recovery tool can salvage individual layers even if
the manifest is damaged.

  • One JSON manifest so the document structure (layer tree, blend modes, masks,

effects) is human-readable and diff-friendly.

1.1 Raster encoding: PNG or lossless WebP

Each raster asset is either PNG or lossless WebP, identified by its file
extension. Both are lossless, so pixels (including alpha) round-trip exactly
either way; WebP is supported from format version 1.0.0 because its lossless mode
is typically 20–40% smaller than PNG.

Rules for a conformant implementation:

  • A reader MUST accept both. Resolve each asset by trying the .webp entry

first, then .png (the manifest references assets by id, _without_ an
extension — see §4). Every modern browser engine and WebView decodes WebP, so
this is safe across platforms.

  • A writer MAY choose either, per asset. It must use lossless WebP only —

never lossy. (DepthField Photo defaults to PNG — fastest to encode — and
offers a desktop Preferences → Project file image format toggle to switch to
lossless WebP for ~20–40% smaller files. WebP is only ever written on desktop,
where a proper encoder is available; in the browser the only canvas-native WebP
path is lossy, so it always writes PNG. A project file must never degrade on
save.)

  • Mixing encodings within one file is allowed — e.g. some layers .webp, a mask

.png. The extension is authoritative.

The two preview images — thumbnail.png and preview.png (§8) — are **always
PNG**, never WebP. They are never read back by the app, and preview.png is the
contract surface for external tools (Finder Quick Look); keeping them PNG with
fixed names makes them trivially locatable without format knowledge.

1.2 Tiles (format 2.0.0)

From format 2.0.0 a layer's pixels and its mask are stored as tiles: the
plane is cut into a grid of 256×256 cells (the right and bottom edge cells
are whatever remains), and each distinct tile is one PNG under tiles/<tileId>.png.
The layer's manifest entry carries a tile record per plane — imageTiles for
the pixels, maskTiles for the mask (§4.1):

"imageTiles": { "width": 3000, "height": 2000, "entries": [[0, "a1b2c3d4-1"], [1, "a1b2c3d4-2"], …] }
  • width / height are the plane's pixel dimensions; the grid has

ceil(width / 256) columns.

  • entries maps a grid key — row × columns + column — to a tile id. A cell

absent from entries is fully transparent (a sparse layer stores only the
tiles it paints).

  • Several keys may name the same id. A tile shared across cells is written

once — a "Reveal All" mask is hundreds of keys pointing at one 256×256 white PNG.

  • Tile ids are opaque strings, unique within the file. Tile entries are STORED

(uncompressed) in the ZIP, since a PNG is already compressed.

Why. DepthField Photo holds a layer as immutable tiles; an edit replaces the
tiles it touched and shares the rest. Writing the file the same way means a save
re-encodes only the tiles that changed since the last one, instead of one PNG per
layer (a 110 MB encode per layer at 42 MP for a two-tile brush stroke).

Readers MUST accept both shapes. A layer meta with a tile record restores from
tiles/; one without falls back to layers/<id>.{png|webp} — the 1.x shape,
which a 2.0.0 writer still uses for a layer whose pixels are not tile-backed.
Object masks and channels stay whole rasters. Tiles are always PNG; the WebP
option in §1.1 applies to the whole-raster assets only.


2. Versioning

Current version: 2.0.1 (2026-10-04). The MAJOR bump to 2.0.0 (2026-09-15) is
the tile layout of §1.2: a 1.x reader finds no layers/<id>.png in a 2.0.0 file
and would open every layer empty, which is a refusal case, not a "some features
may not appear" case. 2.0.0 readers open every 1.x file. The PATCH bump to 2.0.1
adds the optional colorSpace manifest key (§3): a 2.0.0 reader ignores it and
opens the file silently.

Every .dfp file carries a SemVer format version in manifest.formatVersion
(e.g. "2.0.0"). The version describes the _file format_, not the app build that
wrote it.

The three components mean:

BumpWhenAn older app opening such a file
PATCH (1.0.x)Backward- and forward-compatible additions — extra metadata keys older builds simply ignore.Opens silently.
MINOR (1.x.0)New features encoded so older builds can't render them but _can_ safely skip them.Opens, with a one-time "some features may not appear" warning.
MAJOR (x.0.0)A breaking structural change older builds must not attempt to read.Refuses to open and tells the user to update.

In other words, when DepthField Photo opens a file:

  • File major > app major → refuse to open (the structure may be incompatible).
  • Same major, file minor > app minor → open, but show a warning that newer

features may be missing and could be dropped if the file is saved over.

  • Otherwise (same version, older version, or only a newer _patch_) → open

normally.

See §10 for the exact, language-agnostic version-check algorithm.

Pre-SemVer files

Files written before this scheme stored formatVersion as the integer 1.
Readers treat a bare integer n as n.0.0, so those files keep opening with no
special handling. New files always write the SemVer string form.

A malformed or missing formatVersion is treated as 0.0.0 (the oldest possible
version) so the reader errs toward opening rather than refusing.

Note for third-party readers: because the policy keying is "same major →
compatible," always compare versions by component (major/minor/patch), never as
a raw string or float. "1.10.0" is newer than "1.9.0".


3. manifest.json

The manifest is the entry point. It is pretty-printed UTF-8 JSON. Top-level
fields:

FieldTypeDescription
formatVersionstring (SemVer)The .dfp format version this file conforms to (see §2). e.g. "2.0.1".
appNamestringAlways "DepthField Photo". Informational.
createdAtstring (ISO 8601)When the file was written.
documentobject{ id: string, name: string } — the document's stable id and display name.
colorSpacestringThe color space of every pixel in the file. "srgb" is the only value defined (since 2.0.1); absent means "srgb" — see §7.
canvasobjectThe document canvas — see §3.1.
activeLayerIdstring | nullWhich layer was selected when the file was saved.
layersarrayLayer metadata in z-order (bottom-most first) — see §4.
channelsarraySaved selections (alpha channels) — see §6.

3.1 The canvas object

"canvas": {
  "width": 1920,            // document width in pixels
  "height": 1080,           // document height in pixels
  "background": "#ffffff"   // hex color, or "transparent"
}

4. Layers

manifest.layers is an array of layer-metadata objects, ordered bottom-to-top
(the first entry is the lowest in the stack; the last paints on top). The pixel
data for each layer lives in a separate raster asset under layers/, keyed by the
layer's id.

4.1 Common fields (all layer types)

FieldTypeDefaultDescription
idstring—Stable unique id. Used to key the layer's raster asset(s).
namestring—Display name.
typestring"raster"One of raster, text, shape, adjustment, group.
visiblebooleantrueWhether the layer renders.
opacitynumber (0..1)1Layer opacity.
blendModestring"normal"CSS/Konva blend mode (e.g. multiply, screen, overlay).
x, ynumber0Top-left position in canvas space (pixels).
width, heightnumber0Displayed size in canvas space (pixels).
rotationnumber (degrees)0See §7 — raster/text/shape layers are baked to 0 at rest.
scaleX, scaleYnumber1Residual scale; normally 1 (baked into width/height on commit).
parentIdstring | nullnullThe containing group's id, or null for top-level layers.
expandedbooleantrueGroup fold/unfold state in the layer panel (groups only; harmless elsewhere).
clippedbooleanfalseWhen true, the layer is a clipping mask clipped to the first non-clipped layer below it.
mask——Not stored in JSON. Presence of a layers/<id>-mask.{png,webp} asset indicates a mask exists.
maskEnabledbooleantrueWhether the layer mask is active.
objectMasksarray[]Object-mask metadata — see §5. (Pixel data lives in separate raster assets.)
effectsarray[]Non-destructive layer effects — see §4.6.
lockTransparencybooleanfalseLock the layer's alpha (paint only where already opaque).
lockPositionbooleanfalseLock position/transform.
scopestring | nullnullMarker for system-owned groups (e.g. "preset", "auto-enhance").
nameIsAutobooleantrueWhether name is auto-derived from content (cleared once the user renames).
textDataobject | nullnullEditable text source (text layers only) — see §4.3.
pathDataobject | nullnullEditable shape/path source (shape layers only) — see §4.4.
adjustmentDataobject | nullnullAdjustment parameters (adjustment layers only) — see §4.5.

Forward-compatibility: a reader should ignore unknown layer fields rather
than fail. New optional metadata is added under PATCH/MINOR bumps.

4.2 Raster layers (type: "raster")

The simplest layer: its pixels live in tiles/ via the meta's imageTiles record
(§1.2), or — in a 1.x file — in layers/<id>.png / layers/<id>.webp.
width/height are the displayed size; the plane's own dimensions (the record's
width/height, or the image's) are the source pixel resolution. (When they
differ, the layer is being displayed scaled.)

4.3 Text layers (type: "text")

Text layers keep a dual representation: an editable textData source _and_ a
pre-rasterized layers/<id> asset. All downstream consumers (display, export,
flatten) read the raster and treat the layer as raster; textData exists only to
reconstruct the editable text when the user double-clicks it.

textData shape (a sequence of styled "runs" plus one paragraph style):

"textData": {
  "runs": [                       // styled character spans, in order
    {
      "text": "Hello",
      "fontFamily": "Inter",
      "fontWeight": 600,
      "fontSize": 48,
      "fill": "#222222",
      "italic": false,            // optional
      "underline": false,         // optional
      "strikethrough": false,     // optional
      "tracking": 0,              // optional, letter-spacing
      "kerning": []               // optional, sparse per-pair adjustments (1/1000 em)
    }
  ],
  "paragraph": { "align": "left", "leading": null },  // leading: null = auto (1.2× largest size)
  "aa": "smooth"                  // optional antialiasing mode
  // `_dynamic`, `_srcWidth`, `_srcHeight`, `_srcRotation`, `_srcX`, `_srcY`, `_padX`
  // are renderer hints used to reproduce wrapping/rotation; treat as opaque.
}

Fields prefixed _src* / _dynamic / _padX are internal layout hints, not
stable API. A third-party reader can ignore them — the rasterized image is the
visual truth.

4.4 Shape & pen-path layers (type: "shape")

Shape layers also keep an editable pathData source plus a rasterized
layers/<id> asset. pathData:

"pathData": {
  "kind": "rect",              // rect | ellipse | line | polygon | arrow | path
  "fill": "#3b82f6",           // hex or null for no fill
  "fillAlpha": 1,
  "fillGradient": null,        // optional two-stop gradient (see below)
  "stroke": "#1e293b",         // hex or null for no stroke
  "strokeAlpha": 1,
  "strokeWidth": 4,
  "dashed": false,
  "cornerRadius": 8,           // rect only
  "sides": 6,                  // polygon only (3..20)
  "arrowHeadStart": false,     // line/arrow only
  "arrowHeadEnd": true,        // line/arrow only
  "lineStart": "tl",           // line/arrow only: which bbox corner is the start
  "tipLength": 1,              // arrow only
  "path": { "anchors": [ ... ], "closed": true }  // kind="path" only — cubic-bezier anchors
}

For kind: "path", each anchor is
{ x, y, hInX, hInY, hOutX, hOutY, corner } with positions and tangent vectors
normalized 0..1 against the shape's inner bbox, so the path scales with the
layer. Gradients (fillGradient) are likewise bbox-normalized. The fields listed
above are the full set; any key not present takes its noted default.

4.5 Adjustment layers (type: "adjustment")

Adjustment layers have no pixels of their own (no layers/<id> raster). They
carry an adjustmentData object:

"adjustmentData": {
  "type": "curves",     // brightness-contrast | hue-saturation | levels | curves |
                        // color-balance | vibrance | invert | film-grain | ...
  // ...type-specific parameters (the exact keys depend on `type`)
}

An adjustment layer affects all layers below it (or only its clipping base when
clipped is true). It may carry a mask (layers/<id>-mask.{png,webp}) to
localize the effect.

4.6 Layer effects (effects[])

Non-destructive drop shadow / outer glow / outside stroke, stored on any
raster/text/shape layer:

"effects": [
  {
    "id": "fx_abc",
    "type": "drop-shadow",     // drop-shadow | outer-glow | stroke
    "enabled": true,
    "params": { "color": "#000000", "opacity": 0.5, "size": 12, "distance": 8, "angle": 135, "spread": 0, "blendMode": "normal" }
  }
]

Effects render in a fixed order (drop shadow → outer glow → stroke) and are baked
into raster output only on export/flatten — they stay non-destructive in the file.

4.7 Group layers (type: "group")

Groups are pure containers — no imageData, no spatial position. Child layers
reference the group via parentId. Group opacity, visible, blendMode, and an
optional mask apply to the composite of all children. Groups can nest.


5. Object masks

A layer can carry zero or more object masks (e.g. AI-segmented objects).
Metadata lives inline in the layer's objectMasks array; pixel data lives in
layers/<layerId>-objmask-<maskId>.{png,webp}.

"objectMasks": [
  {
    "id": "om_1",
    "name": "Dog",
    "type": "object-mask",     // object-mask | custom
    "visible": false,          // whether the colored overlay is shown
    "color": "#ff0000",        // overlay tint
    "opacity": 0.5,            // overlay opacity
    "objectLabelId": null,     // cross-ref to a detected object label, if any
    "hasCanvas": true          // whether a matching raster asset exists under layers/
  }
]

Each object-mask asset has the same pixel dimensions as the layer's imageData.


6. Alpha channels (saved selections)

Saved selections are stored as alpha channels. Metadata is in
manifest.channels; pixel data is in channels/<channelId>.{png,webp} (grayscale,
where white = selected).

"channels": [
  { "id": "ch_1", "name": "Subject", "updatedAt": 1717977600000 }
]

7. Mask, coordinate & rendering conventions

These invariants make .dfp simple to consume correctly:

  • Layer masks are grayscale-on-opaque rasters (R=G=B encodes the mask, alpha is

always 255). White = fully visible, black = fully hidden, grays = partial.

  • _Legacy note:_ older files stored alpha-encoded masks (RGB = white, alpha =

mask). DepthField Photo detects and normalizes those on load; new files
always write grayscale-on-opaque. A reader that wants to support old files
should treat a mask whose RGB is uniformly white as alpha-encoded.

  • A layer mask's dimensions match the layer's imageData (or the canvas size

for non-raster layers).

  • Rotation is baked. Raster, text, and shape layers are stored with

rotation: 0 — any rotation the user applied has been re-rasterized into the
pixel data and reset. A non-zero rotation on those types would be a defect.
(Adjustment/group layers may carry geometric rotation.)

  • Coordinates are in canvas space. x/y/width/height describe where and

how big the layer is on the document canvas. The asset's intrinsic size is the
source pixel resolution; if it differs from width/height, the layer is shown
scaled.

  • Pixel data is always lossless (PNG or lossless WebP) with straight alpha — no

premultiplication assumptions, no quality loss across save/open cycles,
regardless of which of the two encodings an asset uses (see §1.1).

  • Pixels are sRGB. The working space is sRGB, so the numbers in every tile,

layer, mask and preview.png are sRGB values, declared by
manifest.colorSpace: "srgb" (since 2.0.1; files before that omit the key and
are sRGB all the same). The color space is declared in the manifest rather than
embedded as an ICC profile in each raster: the rasters are internal storage, and
one declaration is cheaper and cannot disagree with itself. A reader that sees a
value other than "srgb" should treat it as a file from a newer writer. (The
image formats DepthField Photo _exports_ — JPEG, PSD, WebP — do embed an sRGB
ICC profile; the .dfp container is not one of them.)


8. Optional sidecar files

FileContent
thumbnail.pngA ≤256 px small in-app preview — a faithful flattened composite (honors masks, adjustments, effects, blend modes). For file browsers / recents. Always PNG. Never read back when opening.
preview.pngA ≤1024 px faithful flattened composite of the whole document — the contract surface for external consumers (e.g. a Finder Quick Look thumbnail/preview extension) that must show the document without parsing the format: unzip this one entry and you have an accurate render. Always PNG. Never read back by the app.
palette.jsonA JSON array of hex color strings — the document's extracted/curated palette.
exif.jsonThe EXIF metadata object carried over from the source photo, if any.

All of these are optional; a valid .dfp needs only manifest.json plus whatever
layer assets the manifest references. Both previews are derived from a single
faithful flatten on save; if that render fails, the writer soft-falls back to a
simplified thumbnail.png (and omits preview.png) so a save can never fail
because a preview couldn't be produced.


9. A complete (minimal) example

A two-layer document — a raster photo with a masked "Curves" adjustment above
it. The raster layer's pixels are tile-backed (§1.2); the adjustment layer's
mask happens to be tile-backed too. A conformant reader would also accept a
1.x-shaped variant of the same file where either plane instead lived in
layers/<id>.{png|webp} and the corresponding imageTiles / maskTiles
records were absent — that mix is legal in a single 2.0.0 file.

manifest.json:

{
	"formatVersion": "2.0.1",
	"appName": "DepthField Photo",
	"createdAt": "2026-10-04T12:00:00.000Z",
	"document": { "id": "doc_a1", "name": "Sunset" },
	"colorSpace": "srgb",
	"canvas": { "width": 1920, "height": 1080, "background": "#ffffff" },
	"activeLayerId": "lyr_photo",
	"layers": [
		{
			"id": "lyr_photo",
			"name": "Background",
			"type": "raster",
			"visible": true,
			"opacity": 1,
			"blendMode": "normal",
			"x": 0,
			"y": 0,
			"width": 1920,
			"height": 1080,
			"rotation": 0,
			"scaleX": 1,
			"scaleY": 1,
			"parentId": null,
			"maskEnabled": true,
			"objectMasks": [],
			"effects": [],
			"clipped": false,
			"nameIsAuto": false,
			"imageTiles": {
				"width": 1920,
				"height": 1080,
				"entries": [
					[0, "t-photo-01"],
					[1, "t-photo-02"],
					[2, "t-photo-03"]
				]
			}
		},
		{
			"id": "lyr_curves",
			"name": "Curves",
			"type": "adjustment",
			"visible": true,
			"opacity": 1,
			"blendMode": "normal",
			"x": 0,
			"y": 0,
			"width": 1920,
			"height": 1080,
			"rotation": 0,
			"scaleX": 1,
			"scaleY": 1,
			"parentId": null,
			"maskEnabled": true,
			"objectMasks": [],
			"effects": [],
			"clipped": false,
			"adjustmentData": {
				"type": "curves",
				"points": [
					[0, 0],
					[128, 150],
					[255, 255]
				]
			},
			"maskTiles": {
				"width": 1920,
				"height": 1080,
				"entries": [
					[0, "t-mask-white"],
					[1, "t-mask-white"],
					[2, "t-mask-white"]
				]
			}
		}
	],
	"channels": []
}

The imageTiles / maskTiles entries are truncated for readability — the
real records list every populated grid cell (up to ceil(1920/256) × ceil(1080/256) = 8×5 = 40
cells per plane). Notice how the mask's three entries all point at the same
t-mask-white id: a "reveal all" mask is stored as ONE white 256×256 PNG with
many keys sharing it (§1.2). Tile ids are opaque — any unique string works.

ZIP entries. A desktop-written file uses lossless WebP for the whole-raster
assets (object masks and channels, plus any non-tile layer); a browser-written
one would use .png for the same assets. Tiles themselves are ALWAYS PNG and
ALWAYS STOREd uncompressed inside the ZIP (§1.2).

manifest.json
thumbnail.png                  ← small in-app preview (always PNG)
preview.png                    ← ≤1024 px faithful flatten for Quick Look (always PNG)
tiles/t-photo-01.png           ← the photo's pixels — one 256×256 PNG per tile
tiles/t-photo-02.png
tiles/t-photo-03.png
tiles/t-mask-white.png         ← the curves adjustment's mask (one white tile, shared)

(There is no layers/lyr_curves.* — adjustment layers have no pixels of their
own — and no layers/lyr_photo.* because that layer's pixels are tile-backed
under tiles/.)


10. Reading & writing in practice

A .dfp is just a ZIP of JSON and lossless rasters, so you can read or write it
in any language with a standard ZIP library and a PNG/WebP codec — no
DepthField-specific tooling required.

To read a file:

  1. Open the ZIP and parse manifest.json.
  2. Check formatVersion against the highest version you support (algorithm

below). Refuse, warn, or proceed accordingly.

  1. Walk manifest.layers (bottom-to-top). For each layer, resolve its raster

assets by trying .webp first, then .png (§1.1): layers/<id> for its
pixels (absent for adjustment/group layers), layers/<id>-mask for its mask,
and any layers/<id>-objmask-<maskId> for object masks.

  1. Resolve saved selections from channels/<id> (same .webp-then-.png rule)

per manifest.channels.

  1. Read palette.json / exif.json if present. Ignore thumbnail.png and

preview.png unless all you want is a quick rendered preview — preview.png
alone is an accurate flattened image, no format parsing required.

To write a file: produce the same entries — a manifest.json whose
formatVersion is the version you target, one lossless raster per layer / mask /
object-mask / channel keyed by id (PNG, or lossless WebP), and the optional
sidecars.

Version-check algorithm (the open / warn / refuse policy from §2):

file = parse(manifest.formatVersion)   # {major, minor, patch}
                                       #   integer n  → n.0.0   (pre-SemVer files)
                                       #   missing/junk → 0.0.0
app  = the highest format version your reader supports

if   file.major  > app.major:                          refuse to open
elif file.major == app.major and file.minor > app.minor: open, but warn
else:                                                   open normally

Always compare by component (major, then minor, then patch) — never as a raw
string or float, or "1.10.0" would sort below "1.9.0".