LogoZeru
Overview — commit history + diff view
Overview — commit history + diff viewLoading…
Case studySide Project · 2026

Krita VC

A desktop version-control client built specifically for Krita's .kra art files, backed by a version-control engine written from scratch rather than wrapping Git. Git treats a .kra as an opaque binary blob and can't diff or dedup it.

01At a glance

Role
Solo developer — architecture, Rust backend, React frontend, and Krita plugin
Stack
Rust (Tauri 2) backend + React 19.1 / TypeScript 5.8 / Vite 7 / Tailwind v4 frontend; content-addressed object store on disk (no external database); rayon (parallelism), blake3 (hashing), zstd + qbsdiff (compression/delta), bincode (storage encoding)
Scale
~9,900 lines of Rust engine code, ~8,300 lines of TS/TSX frontend, 32 Tauri IPC commands across 17 source files under src-tauri/src/
Tests / CI
83 Rust integration tests (src-tauri/tests/) covering commit, branch, merge, stash, GC, and migrations, plus a release-mode benchmark (bench.rs, #[ignore]d) targeting <10s commit/switch/rollback/diff; no frontend test runner yet

02Context & constraints

Context — Krita artists have no real version control: copying .kra files by hand or bolting on git means meaningless diffs (git shows a blob changed, not which layer) and duplicated multi-hundred-MB files on every save.

Constraints — Must run entirely local (no server, no account, no cloud sync — an explicit non-goal), stay usable on modest hardware (large .kra files, HDDs, 4 GB machines), and integrate with Krita itself via a CLI the PyKrita plugin shells out to, not just a standalone viewer.

03The problem

Build a version-control engine that understands Krita's tiled zip format well enough to store only changed tiles, produce a visual diff fast enough to feel instant on documents with thousands of tiles, and let an artist reconcile a parked change instead of losing it.

04Architecture & key decisions

01·Custom .kvc/ store instead of libgit2

Wrote my own content-addressed object store with tile-level bsdiff deltas against Krita's own tile grid, rather than wrapping Git.

tradeoff → I own a lot more code (chains, packs, GC), but Git's opaque-blob model would never let me dedup or diff a .kra's internal tiles.

02·Per-file sharded chain storage

Delta history is split into one file per tracked file (.kvc/chains/, lazy-loaded) instead of one monolithic file rewritten on every commit.

tradeoff → More small files on disk, in exchange for commit cost that no longer scales with total repo history.

03·Pack files for large commits

A commit adding ≥32 new objects gets bundled into one .pack file instead of one file per object.

tradeoff → On Windows, per-file creation (Defender's real-time screening) ate ~28s of a 33s initial commit. Parallelism can't hide that — the file creation itself is slow. Batching was the only fix.

04·Two-stage streamed diffing

commit_diff returns the capped composite image + layer metadata immediately; the expensive per-layer rasters stream in afterwards over a Tauri Channel as rayon finishes each one in parallel.

tradeoff → More IPC plumbing (streamed messages merged by layer id on the frontend) for a diff panel that paints instantly instead of blocking on the slowest layer.

05·Proudest decision: O(N) garbage collection

Mark-and-sweep GC threads one content-hash memo through the marking pass instead of replaying each file's whole patch chain independently per commit.

tradeoff → Turned marking a long-lived repo's history from a quadratic (seconds-scale) operation into a linear one (milliseconds).

06·Stash with .kra-aware conflict resolution

On a conflicting stash pop, instead of hard-refusing like git, I parse both .kra archives and merge at the layer level. Only layers added or modified in the stash fold into the working file, matched by layer UUID and diffed on canonicalized tile content, not raw XML — Krita rewrites volatile attributes and reshuffles tile order on every save.

tradeoff → Real complexity concentrated in merge.rs for one specific case, but it's the difference between your parked work merging in automatically and it being gone.

07·Headless kvc CLI as the Krita integration seam

The whole engine sits behind a second, Tauri-free binary (kvc) that speaks JSON over stdout, rather than exposing the VCS only through Tauri IPC calls the desktop app owns.

tradeoff → A second entry point to maintain (argument parsing, a lock file so it can't race the desktop app's writes) — but it lets the PyKrita plugin commit, checkpoint, discard, and switch branches without leaving Krita. The desktop app stays the history/diff/merge surface; Krita becomes a first-class client of the same store.

05Results & tradeoffs

  • <10s target for commit/switch/rollback/diff on a synthesized Krita-scale document (3200×3200px, 3 layers, 2,500 tiles/layer), verified by a release-mode benchmark
  • Windows large-commit time cut from ~33s (dominated by per-file object creation) down to a few seconds via packfile batching
  • Composite storage moved from a full PNG per commit to content-addressed 256px pixel blocks. The delta store's win is structural: unchanged layers and tiles cost zero additional bytes, and the in-app Performance tab reports the savings per-repository against a naive 'one full copy per version' baseline
  • 83 Rust integration tests cover the engine directly: commit, branch, merge, stash, GC, migrations — correctness I own instead of inheriting from git. No frontend test runner yet, so frontend correctness rests on manual verification
  • Un-benchmarked: real-world p95 on user machines hasn't been collected yet. The bench synthesizes a worst-case document, not production usage

06What I'd do differently

  • I'd add a frontend test runner earlier. A meaningful share of the trickiest logic (streamed-layer merging, shared zoom/pan sync between diff panes) lives in TypeScript with no automated coverage today.
  • Several tuning constants (the 64 KB delta-vs-snapshot threshold, the 512 MB scan-retention budget, the 256 MB cache size) are reasoned defaults, not measured against real usage — I'd revisit them once the app has actual users to profile.
  • I'd wire the large-canvas benchmark into scheduled CI instead of leaving it #[ignore]d and manual — a regression in commit/diff latency only surfaces if someone remembers to run it by hand.