Skip to content
File

Blob: docs/ARCHITECTURE.md

Markdown130 lines

wimp - Architecture

Three layers, deliberately separated. React (with the compiler) owns the UI; the heavy pixel work and encoding live in a Web Worker.

Layer map

Layer 1  Interactive UI (main thread, React 19 + compiler)
         - react-easy-crop: crop / zoom / rotation / flip, live CSS-filter color
         - toolbar, panels, template picker popover, export panel
                         |
                         |  ExportRequest (source bytes transferred, not copied)
                         v
Layer 2  Pixel processing (Web Worker, src/workers/export.worker.ts)
         - decode via createImageBitmap (EXIF-aware)
         - rotate + flip into the rotation bounding box (OffscreenCanvas)
         - crop + resample to target size:
             libvips lanczos3 (if downscaling and available) else canvas 'high'
         - bake color adjustments; flatten JPEG onto white; apply size cap
                         |
                         v
Layer 3  Encoding (same worker)
         - jSquash MozJPEG / WebP / OxiPNG / AVIF (lazy per format)
         - fallback: OffscreenCanvas.convertToBlob
                         |
                         |  ExportResult { blob, width, height, bytes, engine, encoder }
                         v
         Main thread: download + result preview

Data flow

  1. A file enters via drag/drop, click, or clipboard paste (src/App.tsx, src/components/Dropzone.tsx). lib/image.ts loads it into an object URL and reads its natural size.
  2. react-easy-crop (src/components/EditorCanvas.tsx) displays it. The editor state (crop, zoom, rotation, flip, aspect, output, adjustments, format, quality, maxEdge, template id, croppedAreaPixels) lives in a reducer (src/hooks/useEditor.ts).
  3. On export, App reads file.arrayBuffer() and posts an ExportRequest to the worker through lib/exportClient.ts. The ArrayBuffer is transferred (zero copy); a fresh buffer is read for each export so re-export works.
  4. exportClient correlates requests and responses by an incrementing id and resolves a promise with the ExportResult. The worker is a lazily created singleton; if it crashes, pending promises reject and the next call spins up a new one.
  5. App triggers a download and shows the result (size, dimensions, which engine and encoder ran, plus a preview thumbnail with Save and Copy actions).

Coordinate model (crop + rotate + flip)

This mirrors react-easy-crop's official getCroppedImg recipe so the export matches the preview exactly:

  • croppedAreaPixels (from onCropComplete) is expressed in the space of the rotated image's bounding box.
  • The worker rebuilds that exact space: size a canvas to rotateSize(w, h, deg), translate to center, rotate, scale(flip), translate back by -w/2,-h/2, draw.
  • Flip is applied identically in the preview (Cropper "transform" prop using rotateY/rotateX 180) and in the export canvas, so the same croppedAreaPixels selects the same content in both.

Shared filter string

src/lib/filter.ts builds one CSS/canvas filter string from the Adjustments (brightness, contrast, saturate, grayscale, sepia, hueRotate). The UI uses it as react-easy-crop mediaStyle.filter; the worker uses it as ctx.filter. One source of truth keeps preview and export identical.

Engines

  • Pixel engine: libvips (lanczos3) for meaningful downscales when it initializes and the page is cross-origin isolated; otherwise OffscreenCanvas drawImage with imageSmoothingQuality 'high'. See src/lib/vips.ts for the guarded loader.
  • Encoder: jSquash WASM codecs for real quality control; canvas convertToBlob as fallback. PNG is lossless (quality slider disabled).

File map

index.html                      entry, inline SVG favicon
vite.config.ts                  react + react-compiler (babel) + tailwind,
                                COOP/COEP headers, wasm optimizeDeps.exclude
tsconfig.json                   single config, DOM + WebWorker libs, skipLibCheck
src/
  main.tsx                      React root
  index.css                     Tailwind import + @theme tokens + base styles
  types.ts                      Template, Adjustments, ExportRequest/Result, msgs
  templates.ts                  preset data + helpers (grouped)
  hooks/useEditor.ts            reducer: all editor state + actions
  lib/
    image.ts                    load, filename, download, byte/MP formatting
    exportClient.ts             main-thread handle to the worker
    filter.ts                   shared CSS/canvas filter builder
    vips.ts                     guarded lazy wasm-vips loader
    cn.ts                       classnames helper
  workers/export.worker.ts      decode -> transform -> encode
  components/
    App is at src/App.tsx       layout, file handling, shortcuts, export flow
    TopBar.tsx                  header
    Dropzone.tsx                empty-state loader
    EditorCanvas.tsx            Cropper wrapper + framing guide + Fit control
    TemplatePicker.tsx          popover
    panels.tsx                  Crop / Transform / Effects / Adjust / Export
    ui.tsx                      Button, Section, Segmented
    Slider.tsx                  labeled range input
    icons.tsx                   inline SVG icons
docs/                           this documentation

State shape (reducer)

EditorState {
  crop: { x, y }            // react-easy-crop pan position
  zoom: number              // 1..8
  rotation: number          // degrees, wrapped 0..360
  flipH, flipV: boolean
  aspect: number | null     // null = source ratio
  output: { width, height } | null   // null = native cropped size
  maxEdge: number | null    // optional long-edge cap
  adjustments: Adjustments
  templateId: string        // 'custom' when aspect set manually
  format: 'jpeg'|'webp'|'png'|'avif'
  quality: number           // 1..100 (ignored for png)
  croppedAreaPixels: PixelRect | null
}