feat: add worker threads, persistent Python sidecar, graceful shutdown, and architectural improvements

- Graceful shutdown: SIGTERM/SIGINT handlers drain HTTP, stop workers, close DB
- Thumbnail caching: disk-cached thumbnails with immutable Cache-Control headers
- Worker thread pool: Piscina offloads Sharp processing off the main event loop
- Persistent Python dispatcher: pre-imports ML libraries, eliminates cold-start latency
- Tool page registry: declarative tool-to-component mapping replaces 750-line switch
- File store cleanup: remove dead derived fields, stable files array reference
- Job persistence: progress written to SQLite jobs table, stale jobs recovered on startup
This commit is contained in:
Siddharth Kumar Sah
2026-03-29 17:23:41 +08:00
parent 88729e255d
commit 1cbdfa1590
20 changed files with 1575 additions and 530 deletions
+5 -3
View File
@@ -1,6 +1,6 @@
# AI engine
The `@stirling-image/ai` package wraps Python ML models in TypeScript functions. Each operation spawns a Python subprocess, processes the image, and returns the result. The bridge layer handles serialization and error propagation.
The `@stirling-image/ai` package wraps Python ML models in TypeScript functions. A persistent Python dispatcher process pre-imports heavy ML libraries at startup and keeps them warm in memory, eliminating the cold-start latency that would otherwise occur on every request. If the dispatcher is unavailable, the bridge falls back to spawning a fresh subprocess per call.
All model weights are bundled in the Docker image during the build. No downloads happen at runtime.
@@ -76,10 +76,12 @@ Takes an image and a mask (white = area to erase, black = keep). Returns the inp
The TypeScript bridge (`packages/ai/src/bridge.ts`) exposes a single function, `runPythonWithProgress`, that does the following for each AI call:
1. Writes the input image to a temp file in the workspace directory.
2. Spawns a Python subprocess with the appropriate script and arguments.
2. Sends a JSON request to the persistent Python dispatcher via stdin (`packages/ai/python/dispatcher.py`). If the dispatcher isn't running, falls back to spawning a fresh subprocess.
3. Parses JSON progress lines from stderr (e.g. `{"progress": 50, "stage": "Processing..."}`) and forwards them via an `onProgress` callback for real-time SSE streaming.
4. Reads stdout for JSON output.
4. Reads the JSON response from stdout.
5. Reads the output image from the filesystem.
6. Cleans up temp files.
The persistent dispatcher pre-imports rembg, OpenCV, NumPy, and Pillow at startup. This means the first AI call after container start is fast instead of waiting for library imports. The dispatcher handles requests sequentially (Python's GIL) and reports readiness via a `{"ready": true}` message on stderr.
If the Python process exits with a non-zero code, the bridge extracts a user-friendly error from stderr/stdout and throws. Timeouts default to 5 minutes.
+9 -6
View File
@@ -27,7 +27,7 @@ This package has no network dependencies and runs entirely in-process.
### `@stirling-image/ai`
A bridge layer that calls Python scripts via child processes. Each AI capability has a TypeScript wrapper that spawns a Python subprocess, passes image data through the filesystem, and returns the result.
A bridge layer that calls Python scripts for ML operations. On first use, the bridge starts a persistent Python dispatcher process that pre-imports heavy libraries (rembg, OpenCV, NumPy) and keeps them warm in memory. Subsequent AI calls skip the import overhead entirely. If the dispatcher is unavailable, the bridge falls back to spawning a fresh Python subprocess per request.
Supported operations:
- **Background removal** -- BiRefNet-Lite model via rembg
@@ -56,7 +56,9 @@ A Fastify v5 server that handles:
- Swagger/OpenAPI documentation at `/api/docs`
- Serving the built frontend as a SPA in production
Key dependencies: Fastify, Drizzle ORM, better-sqlite3, Sharp, Zod for validation.
Key dependencies: Fastify, Drizzle ORM, better-sqlite3, Sharp, Piscina (worker thread pool), Zod for validation.
The server handles graceful shutdown on SIGTERM/SIGINT: it drains HTTP connections, stops the worker pool, shuts down the Python dispatcher, and closes the database.
### Web (`apps/web`)
@@ -74,10 +76,11 @@ This VitePress site. Deployed to GitHub Pages automatically on push to `main`.
1. The user picks a tool in the web UI and uploads an image.
2. The frontend sends a multipart POST to `/api/v1/tools/:toolId` with the file and settings.
3. The API route validates the input with Zod, auto-orients the image based on EXIF metadata (so camera photos display correctly after processing), then calls the appropriate package function -- either `@stirling-image/image-engine` for standard operations or `@stirling-image/ai` for ML tasks.
4. For AI tools, the TypeScript bridge spawns a Python subprocess, waits for it to finish, and reads the output file.
5. The API returns a `jobId` and `downloadUrl`. The frontend can poll `/api/v1/jobs/:jobId/progress` via SSE for real time status on longer tasks.
6. The user downloads the processed image from `/api/v1/download/:jobId/:filename`.
3. The API route validates the input with Zod, then dispatches processing.
4. For standard tools, the request is offloaded to a Piscina worker thread pool so Sharp operations don't block the main event loop. The worker auto-orients the image based on EXIF metadata, runs the tool's process function, and returns the result. If the worker pool is unavailable, processing falls back to the main thread.
5. For AI tools, the TypeScript bridge sends a request to the persistent Python dispatcher (or spawns a fresh subprocess as fallback), waits for it to finish, and reads the output file.
6. Job progress is persisted to the `jobs` SQLite table 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 image from `/api/v1/download/:jobId/:filename`.
For pipelines, the API feeds the output of each step as input to the next, running them sequentially.
+15 -1
View File
@@ -159,7 +159,21 @@ export function MyToolSettings() {
}
```
Then add the route and component to the tool registry in the frontend.
Then register it in the frontend tool registry at `apps/web/src/lib/tool-registry.tsx`:
```tsx
// Add the lazy import
const MyToolSettings = lazy(() =>
import("@/components/tools/my-tool-settings").then((m) => ({
default: m.MyToolSettings,
})),
);
// Add to the toolRegistry Map
["my-tool", { displayMode: "before-after", Settings: MyToolSettings }],
```
Display modes: `"side-by-side"`, `"before-after"`, `"live-preview"`, `"no-comparison"`, `"interactive-crop"`, `"interactive-eraser"`, `"no-dropzone"`.
### 3. i18n entry