
Avnac Studio
April 2026
4 min read
Principal Engineer (contract)
Saraswati Engine - Extracting a Design Tool from Fabric.js
Avnac Studio's desktop port was blocked by Fabric.js acting as the document model, not just the renderer. We extracted scene logic into a renderer-agnostic engine without pausing the product.
Incremental
Migration
Deterministic
Undo/redo
Swappable
Renderers
Built the Saraswati rendering engine for Avnac Studio, an open-source desktop design tool.
At a glance
Problem
Porting Avnac Studio from web to desktop could not ship while Fabric.js remained the document model. Undo/redo was unreliable, coordinates diverged from stored values, and every save carried renderer-specific JSON the product did not own.
Approach
Introduced Saraswati: scene as a plain value, all changes through commands, render commands as a swappable contract, and Avnac-owned document format. Migrated incrementally with a Fabric-to-scene adapter so shipping continued.
Result
Desktop shipped with multi-page workspaces and reliable undo/redo. The product can adopt new renderers without rewriting editor logic. Open-source code gives technical buyers direct visibility before a call.
Write-up
Context
The web product used Fabric.js directly on the canvas. The desktop rewrite (Wails + Go + React) needed OS-native file storage, lighter binaries, and a document format that could outlive any one renderer.
Leadership had two options: embed Fabric again, or decouple scene logic from rendering. They chose extraction because:
- IndexedDB on web would not translate to per-workspace folders on disk.
- Fabric ties scene state to its runtime. Any renderer change risked breaking undo and saved files.
- A design tool that lasts needs to swap Canvas2D for WebGL or native compositing without rewriting the editor.
The product could not pause for a rewrite. Migration had to be incremental.
The dependency problem
Fabric was not failing because it is a bad library. It was failing because it sat too deep in the stack:
- Serialization was Fabric JSON. Saves included
ActiveSelection, corner styles, and other internals the product did not define. - Undo was post-hoc. Diffs were recorded after mutations. Reversing state was guesswork.
- Coordinates were ambiguous. A shape at (400, 300) on screen could store as (500, 400) depending on origin settings. Three parts of the codebase computed bounds differently.
- Tests needed a browser. You could not validate scene logic without a live canvas.
- Export needed Fabric running. Even PNG export required the full runtime.
The coordinate bug made the cost visible: migrated positions were off by half the shape dimensions until we normalized Fabric's origin model to a top-left scene space. That fix only worked because we stopped treating Fabric as source of truth.
// Normalize Fabric origin to scene top-left
const x =
fabricObj.left - (fabricObj.originX === "center" ? fabricObj.width / 2 : 0);
const y =
fabricObj.top - (fabricObj.originY === "center" ? fabricObj.height / 2 : 0);Constraints
- Desktop had to keep shipping during migration.
- React UI needed predictable scene updates.
- File format had to survive cold storage and future renderers.
- Canvas performance could not regress from per-frame allocation churn.
- Team size did not allow a big-bang rewrite.
Approach
Treat the scene as a value, not a runtime.
1. Plain scene graph
SaraswatiScene is a serializable object: nodes, transforms, fills. No Fabric instances, no hidden renderer state.
type SaraswatiRectNode = {
id: string;
type: "rect";
x: number;
y: number;
width: number;
height: number;
rotation: number;
scaleX: number;
scaleY: number;
fill: BgValue;
stroke: BgValue | null;
strokeWidth: number;
};One pure getNodeBounds() drives hit testing, inspector values, and selection overlays.
2. Commands, not mutations
Every edit is a command applied by a reducer. The scene is immutable per step.
interface SaraswatiCommand {
type: "MOVE_NODE" | "ROTATE_NODE" | "RESIZE_NODE" | "ADD_NODE" | ...;
id: string;
payload?: unknown;
}
function applyCommand(scene: Scene, command: Command): Scene {
// Returns new scene. Original unchanged.
}Undo replays commands in reverse. Drag, keyboard, inspector, and import all funnel through the same path.
3. Render commands as a boundary
The engine emits drawing instructions, not canvas calls:
type SaraswatiRenderCommand =
| { type: "rect"; x: number; y: number; width: number; height: number; fill: BgValue; ... }
| { type: "text"; text: string; fontSize: number; fontFamily: string; ... }
| { type: "image"; src: string; cropX: number; cropY: number; ... }
| { type: "line"; x1: number; y1: number; x2: number; y2: number; ... }
| { type: "__artboard__"; width: number; height: number; background: string };Canvas2D implements the contract today. A future WebGL or native backend implements the same list without importing scene modules.
4. Avnac-owned document format
Saved files are Saraswati scenes, not Fabric exports:
{
"artboard": { "width": 1920, "height": 1080, "background": "#ffffff" },
"nodes": {
"rect-1": {
"type": "rect",
"x": 100,
"y": 100,
"width": 200,
"height": 150,
"fill": { "r": 255, "g": 0, "b": 0 }
}
}
}No renderer-specific fields. Intent is explicit.
5. Incremental migration
- Introduced a
scene-workspace/boundary where legacy Fabric and Saraswati coexisted. - Swapped the visible surface to Saraswati progressively.
- Shipped a read-only adapter from legacy Fabric JSON to
SaraswatiScene. - New saves are Saraswati documents. Fabric shapes are not carried forward.
What changed
| Area | Before | After |
|---|---|---|
| Document model | Fabric objects | Plain SaraswatiScene |
| Undo/redo | Fragile diffs | Command replay |
| Mutations | Scattered handlers | Central reducer |
| Rendering | Fabric-only | Swappable backends |
| Tests | DOM + canvas required | Pure functions in Node |
| File format | Fabric JSON | Avnac-owned schema |
| Bounds | Three divergent implementations | One pure function |
Outcome
- Undo/redo is reliable under normal editing load.
- Scene logic is renderer-agnostic. Canvas2D is the current backend, not a permanent lock-in.
- Interaction math and commands run in Node tests without a browser.
- Desktop shipped with multi-page workspaces and vector boards.
- Render commands enable headless export without a visible canvas.
- Command streams serialize cleanly for future collaboration work.
Key takeaway
When a third-party library becomes your document model, you inherit its bugs, its JSON, and its upgrade path. Extracting scene logic costs upfront — commands, render boundaries, migration adapters — but pays back in reliable undo, testable math, and renderers you can swap without betting the product again.
Saraswati engine
For a long-lived design tool, that extraction is not polish. It is how you keep shipping while the platform moves.
Technical appendix
Technical problem
Fabric coupled scene state, hit testing, serialization, and rendering. Direct mutations scattered across handlers. Tests required a live DOM. Export and undo depended on a running Fabric canvas.
Technical approach
Introduced Saraswati: scene as a plain value, all changes through commands, render commands as a swappable contract, and Avnac-owned document format. Migrated incrementally with a Fabric-to-scene adapter so shipping continued.
Technical outcome
Deterministic command replay for undo. Pure bounds and interaction math testable in Node. Canvas2D renderer today; WebGL or native backends later without touching scene modules.
Related case studies
Avnac Studio
Boreas - Job Queue Architecture for Background Removal at Scale
Built a fast, stateless background-removal API that decouples image upload validation from expensive compute work using Redis queues and worker pools, keeping request latency under 200ms regardless of processing time.
Read case study →SMS activation platform
Automated Expiry Detection for Virtual Numbers
Built an automated expiry detection and refund system for temporary virtual numbers using Redis sorted sets when the provider offered no webhook support.
Read case study →