SnapOtter is a monorepo managed with pnpm workspaces and Turborepo. It deploys as a 3-container Docker Compose stack: the SnapOtter app image, PostgreSQL 17, and Redis 8.
The core image processing library built on [Sharp](https://sharp.pixelplumbing.com/). It handles all non-AI operations: resize, crop, rotate, flip, convert, compress, strip metadata, and color adjustments (brightness, contrast, saturation, grayscale, sepia, invert, color channels).
This package has no network dependencies and runs entirely in-process.
A bridge layer that calls native and Python ML runtimes. Most Python tools use a persistent dispatcher that pre-imports heavy libraries (PIL, NumPy, MediaPipe, rembg) so subsequent calls skip the import overhead. OCR is isolated from that mutable shared environment: `fast` invokes native Tesseract, while `balanced` and `best` use a dedicated persistent JSONL dispatcher pinned to the active immutable RapidOCR/ONNX generation. Each request holds a generation lease. Activation first runs a smoke test on a candidate, then atomically switches to its dispatcher. The prior dispatcher drains before its generation is garbage-collected.
**Models are not pre-loaded.** Each tool script loads its model weights from disk at request time and discards them when the request finishes. See [Resource footprint](#resource-footprint) for the full memory profile.
Python scripts live in `packages/ai/python/`. Large optional model packs are installed on demand into the persistent `/data/ai` volume. Accurate OCR uses signed, platform-specific artifacts; the built-in Tesseract tier requires no model-pack download.
- User file library (`user_files` table): a saved edit is stored as an independent new file by default, or as a parent-linked version when you overwrite the original. It records which tools were applied (`toolChain`) and gets an auto-generated thumbnail for the Files page
- Teams management - admin-only CRUD; users are assigned to a team via the `team` field on their profile
- Runtime settings - a key-value store in the `settings` table that controls `disabledTools`, `enableExperimentalTools`, `loginAttemptLimit`, and other operational knobs without redeploying
The server handles graceful shutdown on SIGTERM/SIGINT: it drains HTTP connections, stops BullMQ workers, shuts down the Python dispatcher, and closes the database connection.
A React 19 single-page app built with Vite. Uses Zustand for state management, Tailwind CSS v4 for styling, and Lucide for icons. Communicates with the API over REST and SSE (for progress tracking).
4. For standard tools, the job is enqueued to the appropriate BullMQ pool (image, media, or docs based on modality). The in-process BullMQ worker auto-orients the image based on EXIF metadata, runs the tool's process function, and returns the result.
5. For most AI tools, the TypeScript bridge sends a request to the persistent Python dispatcher. Fast OCR instead invokes Tesseract, and accurate OCR starts the pinned executable from the active immutable OCR generation. The requested OCR tier is fixed at ingress and is never silently changed during execution.
6. Job progress is persisted to the `jobs` table in PostgreSQL so state survives container restarts. Real-time updates are delivered via SSE at `/api/v1/jobs/:jobId/progress`.
7. The API returns a `jobId` and `downloadUrl`. The user downloads the processed file from `/api/v1/download/:jobId/:filename`.
The Node.js/Fastify process, PostgreSQL, and Redis are running. Typical idle RAM is **~200-300 MB** across all three containers (Node.js process, Postgres, and Redis). No Python process, no model weights in memory.
All model weight files (totalling several GB) sit on disk in `/opt/models/` at all times. Each AI tool script loads only its own model(s) into memory for the duration of a request, then releases them. Some scripts explicitly call `del model` and `torch.cuda.empty_cache()` after inference to ensure memory is returned immediately.
There is no model cache between requests. Running the same AI tool back-to-back reloads the model each time. This keeps idle memory near zero at the cost of a model-load delay on every AI request.
The Python dispatcher is not running when the container starts. The first AI request triggers two things in parallel: the dispatcher starts warming up in the background, and the request itself falls back to a one-off Python subprocess spawn. Once the dispatcher signals ready, all subsequent AI requests use it directly and skip the subprocess spawn cost.