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.dfpcontainer. 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 thecolorSpacemanifest 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
.webpentry
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/heightare the plane's pixel dimensions; the grid has
ceil(width / 256) columns.
entriesmaps 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 fromtiles/; 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:
| Bump | When | An 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:
| Field | Type | Description |
|---|---|---|
formatVersion | string (SemVer) | The .dfp format version this file conforms to (see §2). e.g. "2.0.1". |
appName | string | Always "DepthField Photo". Informational. |
createdAt | string (ISO 8601) | When the file was written. |
document | object | { id: string, name: string } — the document's stable id and display name. |
colorSpace | string | The color space of every pixel in the file. "srgb" is the only value defined (since 2.0.1); absent means "srgb" — see §7. |
canvas | object | The document canvas — see §3.1. |
activeLayerId | string | null | Which layer was selected when the file was saved. |
layers | array | Layer metadata in z-order (bottom-most first) — see §4. |
channels | array | Saved 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)
| Field | Type | Default | Description |
|---|---|---|---|
id | string | — | Stable unique id. Used to key the layer's raster asset(s). |
name | string | — | Display name. |
type | string | "raster" | One of raster, text, shape, adjustment, group. |
visible | boolean | true | Whether the layer renders. |
opacity | number (0..1) | 1 | Layer opacity. |
blendMode | string | "normal" | CSS/Konva blend mode (e.g. multiply, screen, overlay). |
x, y | number | 0 | Top-left position in canvas space (pixels). |
width, height | number | 0 | Displayed size in canvas space (pixels). |
rotation | number (degrees) | 0 | See §7 — raster/text/shape layers are baked to 0 at rest. |
scaleX, scaleY | number | 1 | Residual scale; normally 1 (baked into width/height on commit). |
parentId | string | null | null | The containing group's id, or null for top-level layers. |
expanded | boolean | true | Group fold/unfold state in the layer panel (groups only; harmless elsewhere). |
clipped | boolean | false | When 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. |
maskEnabled | boolean | true | Whether the layer mask is active. |
objectMasks | array | [] | Object-mask metadata — see §5. (Pixel data lives in separate raster assets.) |
effects | array | [] | Non-destructive layer effects — see §4.6. |
lockTransparency | boolean | false | Lock the layer's alpha (paint only where already opaque). |
lockPosition | boolean | false | Lock position/transform. |
scope | string | null | null | Marker for system-owned groups (e.g. "preset", "auto-enhance"). |
nameIsAuto | boolean | true | Whether name is auto-derived from content (cleared once the user renames). |
textData | object | null | null | Editable text source (text layers only) — see §4.3. |
pathData | object | null | null | Editable shape/path source (shape layers only) — see §4.4. |
adjustmentData | object | null | null | Adjustment 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'swidth/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/_padXare 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 rasterizedlayers/<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 whenclipped 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 inlayers/<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 inmanifest.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/heightdescribe 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
| File | Content |
|---|---|
thumbnail.png | A ≤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.png | A ≤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.json | A JSON array of hex color strings — the document's extracted/curated palette. |
exif.json | The 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 inlayers/<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 samet-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:
- Open the ZIP and parse
manifest.json. - Check
formatVersionagainst the highest version you support (algorithm
below). Refuse, warn, or proceed accordingly.
- 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.
- Resolve saved selections from
channels/<id>(same.webp-then-.pngrule)
per manifest.channels.
- Read
palette.json/exif.jsonif present. Ignorethumbnail.pngand
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 whoseformatVersion 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 normallyAlways 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".