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, backend engine, frontend, and CLI
Stack
Rust (Tauri 2) backend + React 19.1 / TypeScript 5.8 / Vite 7 / Tailwind v4 frontend; rayon (parallelism), blake3 (hashing), zstd + qbsdiff (compression/delta), bincode (storage encoding)
Scale
~8,000 lines of Rust engine code, ~5,700 lines of TS/TSX frontend, 50 Rust integration tests
Tests / CI
cargo test (50 tests across engine.rs/kvc_cli.rs) + a release-mode performance benchmark (bench.rs, #[ignore]d) targeting <10s commit/switch/rollback/diff on a synthesized Krita-scale document

02Context & constraints

Context — Krita artists have no good way to version their work. Git exists, but a .kra file is a zip of tiled raster layers, and Git's blob model can only store full copies, never diff or delta-compress it.

Constraints — Must run local-only (no cloud or remote: artists shouldn't need an account or network), stay usable on modest hardware (explicitly benched against 2-core/4 GB-class machines), and integrate with Krita itself via a companion CLI a PyKrita plugin can shell out to.

03The problem

Build a version-control engine that understands Krita's tiled zip format well enough to store only the tiles that actually changed, and produce a visual before/after diff fast enough to feel instant on documents with thousands of tiles.

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) was ~28s of a 33s initial commit. Parallelism can't hide that cost, since it's the file creation itself that's 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).

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 — removed what had been the single largest per-commit storage cost
  • 50 Rust integration tests cover the engine and CLI; no frontend test runner yet — frontend correctness currently rests on manual verification
  • Un-benchmarked: real-world repo growth and diff latency on artists' actual multi-GB projects — the bench synthesizes a worst-case document, not measured 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 default 256 MB cache size) are reasoned defaults, not measured against real usage patterns — I'd revisit these once the app has actual users to profile.