From 1cbdfa15900a58a5db23361167d3ed71d59bf189 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Sun, 29 Mar 2026 17:23:41 +0800 Subject: [PATCH] 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 --- apps/api/package.json | 1 + apps/api/src/index.ts | 53 ++- apps/api/src/lib/cleanup.ts | 13 +- apps/api/src/lib/file-storage.ts | 38 +- apps/api/src/lib/image-worker.ts | 61 +++ apps/api/src/lib/worker-pool.ts | 37 ++ apps/api/src/routes/progress.ts | 123 ++++++ apps/api/src/routes/tool-factory.ts | 53 ++- apps/api/src/routes/user-files.ts | 24 +- apps/docs/api/ai.md | 8 +- apps/docs/guide/architecture.md | 15 +- apps/docs/guide/developer.md | 16 +- apps/web/src/lib/tool-registry.tsx | 304 +++++++++++++ apps/web/src/pages/tool-page.tsx | 663 +++++++++------------------- apps/web/src/stores/file-store.ts | 88 ++-- packages/ai/python/dispatcher.py | 163 +++++++ packages/ai/src/bridge.ts | 231 +++++++++- packages/ai/src/index.ts | 1 + pnpm-lock.yaml | 189 ++++++++ tests/unit/web/stores.test.ts | 24 +- 20 files changed, 1575 insertions(+), 530 deletions(-) create mode 100644 apps/api/src/lib/image-worker.ts create mode 100644 apps/api/src/lib/worker-pool.ts create mode 100644 apps/web/src/lib/tool-registry.tsx create mode 100644 packages/ai/python/dispatcher.py diff --git a/apps/api/package.json b/apps/api/package.json index 48d47b5b..ae39fc3c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -30,6 +30,7 @@ "jsqr": "^1.4.0", "p-queue": "^9.1.0", "pdfkit": "^0.18.0", + "piscina": "^5.1.4", "potrace": "^2.1.8", "qrcode": "^1.5.4", "sharp": "^0.33.0", diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 666c3e5a..b4aaa111 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -6,6 +6,7 @@ import { env } from "./config.js"; import { db, schema } from "./db/index.js"; import { runMigrations } from "./db/migrate.js"; import { startCleanupCron } from "./lib/cleanup.js"; +import { shutdownWorkerPool } from "./lib/worker-pool.js"; import { authMiddleware, authRoutes, ensureDefaultAdmin, requireAdmin } from "./plugins/auth.js"; import { registerStatic } from "./plugins/static.js"; import { registerUpload } from "./plugins/upload.js"; @@ -15,7 +16,7 @@ import { brandingRoutes } from "./routes/branding.js"; import { docsRoutes } from "./routes/docs.js"; import { fileRoutes } from "./routes/files.js"; import { registerPipelineRoutes } from "./routes/pipeline.js"; -import { registerProgressRoutes } from "./routes/progress.js"; +import { recoverStaleJobs, registerProgressRoutes } from "./routes/progress.js"; import { settingsRoutes } from "./routes/settings.js"; import { teamsRoutes } from "./routes/teams.js"; import { registerToolRoutes } from "./routes/tools/index.js"; @@ -28,6 +29,9 @@ console.log("Database initialized"); // Create default admin user if no users exist await ensureDefaultAdmin(); +// Mark any jobs left in processing/queued from a previous unclean shutdown +recoverStaleJobs(); + const app = Fastify({ logger: true, bodyLimit: env.MAX_UPLOAD_SIZE_MB * 1024 * 1024, @@ -143,7 +147,7 @@ if (process.env.NODE_ENV === "production") { } // Start workspace cleanup cron -startCleanupCron(); +const cleanupCron = startCleanupCron(); // Start try { @@ -153,3 +157,48 @@ try { app.log.error(err); process.exit(1); } + +// Graceful shutdown +let shuttingDown = false; +async function shutdown(signal: string) { + if (shuttingDown) return; + shuttingDown = true; + console.log(`\n${signal} received, shutting down gracefully...`); + + cleanupCron.stop(); + + try { + await app.close(); + console.log("HTTP server closed"); + } catch (err) { + console.error("Error closing HTTP server:", err); + } + + try { + await shutdownWorkerPool(); + console.log("Worker pool shut down"); + } catch (err) { + console.error("Error shutting down worker pool:", err); + } + + try { + const { shutdownDispatcher } = await import("@stirling-image/ai"); + shutdownDispatcher(); + console.log("Python dispatcher shut down"); + } catch { + // AI package may not be available + } + + try { + const { sqlite: sqliteConn } = await import("./db/index.js"); + sqliteConn.close(); + console.log("Database connection closed"); + } catch (err) { + console.error("Error closing database:", err); + } + + process.exit(0); +} + +process.on("SIGTERM", () => shutdown("SIGTERM")); +process.on("SIGINT", () => shutdown("SIGINT")); diff --git a/apps/api/src/lib/cleanup.ts b/apps/api/src/lib/cleanup.ts index d6e1ecbd..0638525e 100644 --- a/apps/api/src/lib/cleanup.ts +++ b/apps/api/src/lib/cleanup.ts @@ -43,7 +43,7 @@ export function shouldRunStartupCleanup(): boolean { } } -export function startCleanupCron() { +export function startCleanupCron(): { stop: () => void } { // Ensure workspace directory exists mkdirSync(env.WORKSPACE_PATH, { recursive: true }); @@ -97,9 +97,16 @@ export function startCleanupCron() { } // Schedule recurring cleanup - setInterval(cleanup, intervalMs); - setInterval(purgeExpiredSessions, 60 * 60 * 1000); // Hourly + const cleanupTimer = setInterval(cleanup, intervalMs); + const sessionTimer = setInterval(purgeExpiredSessions, 60 * 60 * 1000); // Hourly console.log( `Cleanup scheduled: every ${env.CLEANUP_INTERVAL_MINUTES}m, max age configurable (env default: ${env.FILE_MAX_AGE_HOURS}h)`, ); + + return { + stop: () => { + clearInterval(cleanupTimer); + clearInterval(sessionTimer); + }, + }; } diff --git a/apps/api/src/lib/file-storage.ts b/apps/api/src/lib/file-storage.ts index 877efdc7..269e43f7 100644 --- a/apps/api/src/lib/file-storage.ts +++ b/apps/api/src/lib/file-storage.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { mkdir, unlink, writeFile } from "node:fs/promises"; +import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { extname, join } from "node:path"; import { env } from "../config.js"; @@ -49,3 +49,39 @@ export async function deleteStoredFile(storedName: string): Promise { export function getStoredFilePath(storedName: string): string { return join(env.FILES_STORAGE_PATH, storedName); } + +// ── Thumbnail cache ────────────────────────────────────────────────── + +const THUMB_DIR = ".thumbs"; +let thumbDirReady = false; + +async function ensureThumbDir(): Promise { + if (thumbDirReady) return; + await mkdir(join(env.FILES_STORAGE_PATH, THUMB_DIR), { recursive: true }); + thumbDirReady = true; +} + +function thumbPath(storedName: string): string { + return join(env.FILES_STORAGE_PATH, THUMB_DIR, `${storedName}.thumb.jpg`); +} + +export async function getCachedThumbnail(storedName: string): Promise { + try { + return await readFile(thumbPath(storedName)); + } catch { + return null; + } +} + +export async function saveThumbnail(storedName: string, buffer: Buffer): Promise { + await ensureThumbDir(); + await writeFile(thumbPath(storedName), buffer); +} + +export async function deleteThumbnail(storedName: string): Promise { + try { + await unlink(thumbPath(storedName)); + } catch { + // Thumbnail may not exist + } +} diff --git a/apps/api/src/lib/image-worker.ts b/apps/api/src/lib/image-worker.ts new file mode 100644 index 00000000..c242ee66 --- /dev/null +++ b/apps/api/src/lib/image-worker.ts @@ -0,0 +1,61 @@ +/** + * Piscina worker that executes image tool processing in a worker thread. + * + * On first call, it imports all tool registration modules using a mock + * Fastify instance (only the registry-populating side effects are needed, + * not the HTTP route registrations). Subsequent calls reuse the populated + * registry for O(1) lookup. + */ +import { autoOrient } from "./auto-orient.js"; + +export interface WorkerInput { + toolId: string; + inputBuffer: Buffer; + settings: unknown; + filename: string; +} + +export interface WorkerOutput { + buffer: Buffer; + filename: string; + contentType: string; +} + +let registryReady = false; + +async function ensureRegistry(): Promise { + if (registryReady) return; + + // Create a minimal mock that satisfies the register functions. + // createToolRoute calls app.post() (no-op here) and toolRegistry.set() (the part we want). + // AI tools also call app.post() and registerToolProcessFn() (also populates the registry). + const mockApp = { + post: () => {}, + get: () => {}, + log: { info: () => {}, warn: () => {}, error: () => {} }, + }; + + const { registerToolRoutes } = await import("../routes/tools/index.js"); + await registerToolRoutes(mockApp as never); + registryReady = true; +} + +export default async function processInWorker(input: WorkerInput): Promise { + await ensureRegistry(); + + const { getToolConfig } = await import("../routes/tool-factory.js"); + const config = getToolConfig(input.toolId); + + if (!config) { + throw new Error(`Tool "${input.toolId}" not found in worker registry`); + } + + const oriented = await autoOrient(Buffer.from(input.inputBuffer)); + const result = await config.process(oriented, input.settings, input.filename); + + return { + buffer: result.buffer, + filename: result.filename, + contentType: result.contentType, + }; +} diff --git a/apps/api/src/lib/worker-pool.ts b/apps/api/src/lib/worker-pool.ts new file mode 100644 index 00000000..3eba2e75 --- /dev/null +++ b/apps/api/src/lib/worker-pool.ts @@ -0,0 +1,37 @@ +/** + * Worker pool for offloading CPU-bound image processing from the main event loop. + * + * Uses Piscina (backed by worker_threads) so Sharp operations don't block + * HTTP request handling, SSE streams, or health checks. + */ +import { availableParallelism } from "node:os"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import Piscina from "piscina"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Size the pool: leave 1 thread for the event loop, min 1 worker +const maxThreads = Math.max(1, Math.min(availableParallelism() - 1, 4)); + +let pool: Piscina | null = null; + +export function getWorkerPool(): Piscina { + if (!pool) { + pool = new Piscina({ + filename: resolve(__dirname, "image-worker.ts"), + // Inherit tsx loader flags from the main process so .ts files work in workers + execArgv: [...process.execArgv], + maxThreads, + idleTimeout: 30000, + }); + } + return pool; +} + +export async function shutdownWorkerPool(): Promise { + if (pool) { + await pool.destroy(); + pool = null; + } +} diff --git a/apps/api/src/routes/progress.ts b/apps/api/src/routes/progress.ts index f263dc78..32bfbba5 100644 --- a/apps/api/src/routes/progress.ts +++ b/apps/api/src/routes/progress.ts @@ -4,8 +4,13 @@ * GET /api/v1/jobs/:jobId/progress * * Sends Server-Sent Events with progress data until the job finishes. + * + * Progress is held in-memory for real-time SSE delivery and also + * persisted to the `jobs` table so that state survives container restarts. */ +import { eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { db, schema } from "../db/index.js"; export interface JobProgress { jobId: string; @@ -34,11 +39,128 @@ const jobProgressStore = new Map(); /** SSE listeners waiting for updates, keyed by jobId. */ const listeners = new Map void>>(); +// ── DB persistence helpers ────────────────────────────────────────── + +function persistJobProgress(progress: JobProgress): void { + try { + const completionRatio = + progress.totalFiles > 0 ? progress.completedFiles / progress.totalFiles : 0; + const existing = db + .select({ id: schema.jobs.id }) + .from(schema.jobs) + .where(eq(schema.jobs.id, progress.jobId)) + .get(); + + if (existing) { + db.update(schema.jobs) + .set({ + status: progress.status, + progress: completionRatio, + error: progress.errors.length > 0 ? JSON.stringify(progress.errors) : null, + completedAt: + progress.status === "completed" || progress.status === "failed" ? new Date() : null, + }) + .where(eq(schema.jobs.id, progress.jobId)) + .run(); + } else { + db.insert(schema.jobs) + .values({ + id: progress.jobId, + type: "batch", + status: progress.status, + progress: completionRatio, + inputFiles: JSON.stringify({ totalFiles: progress.totalFiles }), + error: progress.errors.length > 0 ? JSON.stringify(progress.errors) : null, + }) + .run(); + } + } catch { + // DB persistence is best-effort; don't break real-time SSE + } +} + +function persistSingleFileProgress(progress: Omit): void { + try { + const status = + progress.phase === "complete" + ? "completed" + : progress.phase === "failed" + ? "failed" + : "processing"; + const existing = db + .select({ id: schema.jobs.id }) + .from(schema.jobs) + .where(eq(schema.jobs.id, progress.jobId)) + .get(); + + if (existing) { + db.update(schema.jobs) + .set({ + status, + progress: progress.percent / 100, + error: progress.error ?? null, + completedAt: status === "completed" || status === "failed" ? new Date() : null, + }) + .where(eq(schema.jobs.id, progress.jobId)) + .run(); + } else { + db.insert(schema.jobs) + .values({ + id: progress.jobId, + type: "single", + status, + progress: progress.percent / 100, + inputFiles: "[]", + error: progress.error ?? null, + }) + .run(); + } + } catch { + // Best-effort + } +} + +/** + * Mark any jobs left in "processing" or "queued" state as failed. + * Called once at startup to recover from unclean shutdown. + */ +export function recoverStaleJobs(): void { + try { + const result = db + .update(schema.jobs) + .set({ + status: "failed", + error: "Server restarted while job was in progress", + completedAt: new Date(), + }) + .where(eq(schema.jobs.status, "processing")) + .run(); + const result2 = db + .update(schema.jobs) + .set({ + status: "failed", + error: "Server restarted while job was queued", + completedAt: new Date(), + }) + .where(eq(schema.jobs.status, "queued")) + .run(); + const total = result.changes + result2.changes; + if (total > 0) { + console.log(`Recovered ${total} stale jobs from previous run`); + } + } catch { + // DB not ready + } +} + +// ── Public API (unchanged signatures) ─────────────────────────────── + /** * Create or update progress for a job. */ export function updateJobProgress(progress: JobProgress): void { jobProgressStore.set(progress.jobId, progress); + persistJobProgress(progress); // Notify all SSE listeners const subs = listeners.get(progress.jobId); if (subs) { @@ -57,6 +179,7 @@ export function updateJobProgress(progress: JobProgress): void { export function updateSingleFileProgress(progress: Omit): void { const event: SingleFileProgress = { ...progress, type: "single" }; + persistSingleFileProgress(progress); const subs = listeners.get(progress.jobId); if (subs) { for (const cb of subs) { diff --git a/apps/api/src/routes/tool-factory.ts b/apps/api/src/routes/tool-factory.ts index a54948c5..a53d3e76 100644 --- a/apps/api/src/routes/tool-factory.ts +++ b/apps/api/src/routes/tool-factory.ts @@ -9,6 +9,8 @@ import { db, schema } from "../db/index.js"; import { autoOrient } from "../lib/auto-orient.js"; import { validateImageBuffer } from "../lib/file-validation.js"; import { sanitizeFilename } from "../lib/filename.js"; +import type { WorkerInput, WorkerOutput } from "../lib/image-worker.js"; +import { getWorkerPool } from "../lib/worker-pool.js"; import { createWorkspace } from "../lib/workspace.js"; export interface ToolRouteConfig { @@ -41,6 +43,16 @@ export interface AnyToolRouteConfig { */ const toolRegistry = new Map(); +/** Tools that use the Python bridge and should NOT be offloaded to workers. */ +const SKIP_WORKER_TOOLS = new Set([ + "remove-background", + "upscale", + "ocr", + "blur-faces", + "erase-object", + "smart-crop", +]); + /** * Retrieve a registered tool config by its ID. */ @@ -151,12 +163,43 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig return reply.status(400).send({ error: "Settings must be valid JSON" }); } - // Auto-orient based on EXIF metadata before processing. - const processBuffer = await autoOrient(fileBuffer); - - // Process the image + // Process the image (worker thread or main thread) try { - const result = await config.process(processBuffer, settings, filename); + let result: { buffer: Buffer; filename: string; contentType: string }; + + // Offload to worker thread for non-AI tools. + // Falls back to main-thread processing on any worker error. + // Disabled in test environments where worker_threads can't load .ts files. + const useWorker = !SKIP_WORKER_TOOLS.has(config.toolId) && process.env.NODE_ENV !== "test"; + if (useWorker) { + try { + const pool = getWorkerPool(); + const workerInput: WorkerInput = { + toolId: config.toolId, + inputBuffer: fileBuffer, + settings, + filename, + }; + const workerResult: WorkerOutput = await pool.run(workerInput); + result = { + buffer: Buffer.from(workerResult.buffer), + filename: workerResult.filename, + contentType: workerResult.contentType, + }; + } catch (workerErr) { + // Worker failed - fall back to main-thread processing + request.log.warn( + { workerErr, toolId: config.toolId }, + "Worker processing failed, falling back to main thread", + ); + const processBuffer = await autoOrient(fileBuffer); + result = await config.process(processBuffer, settings, filename); + } + } else { + // AI tools: always main thread (they use Python bridge) + const processBuffer = await autoOrient(fileBuffer); + result = await config.process(processBuffer, settings, filename); + } // Create workspace and save output const jobId = randomUUID(); diff --git a/apps/api/src/routes/user-files.ts b/apps/api/src/routes/user-files.ts index 9eb9ca7c..4273d749 100644 --- a/apps/api/src/routes/user-files.ts +++ b/apps/api/src/routes/user-files.ts @@ -17,7 +17,14 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; import { db, schema, sqlite } from "../db/index.js"; import { auditLog } from "../lib/audit.js"; -import { deleteStoredFile, getStoredFilePath, saveFile } from "../lib/file-storage.js"; +import { + deleteStoredFile, + deleteThumbnail, + getCachedThumbnail, + getStoredFilePath, + saveFile, + saveThumbnail, +} from "../lib/file-storage.js"; import { validateImageBuffer } from "../lib/file-validation.js"; import { sanitizeFilename } from "../lib/filename.js"; import { getAuthUser } from "../plugins/auth.js"; @@ -342,6 +349,15 @@ export async function userFileRoutes(app: FastifyInstance): Promise { return reply.status(404).send({ error: "File not found" }); } + // Serve from disk cache if available + const cached = await getCachedThumbnail(file.storedName); + if (cached) { + return reply + .header("Content-Type", "image/jpeg") + .header("Cache-Control", "public, max-age=86400, immutable") + .send(cached); + } + const filePath = getStoredFilePath(file.storedName); try { @@ -350,9 +366,12 @@ export async function userFileRoutes(app: FastifyInstance): Promise { .jpeg({ quality: 80 }) .toBuffer(); + // Cache to disk (non-blocking, don't fail the request) + saveThumbnail(file.storedName, thumbnail).catch(() => {}); + return reply .header("Content-Type", "image/jpeg") - .header("Cache-Control", "public, max-age=86400") + .header("Cache-Control", "public, max-age=86400, immutable") .send(thumbnail); } catch { return reply.status(422).send({ error: "Could not generate thumbnail" }); @@ -412,6 +431,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise { for (const row of chainRows) { await deleteStoredFile(row.stored_name); + await deleteThumbnail(row.stored_name); db.delete(schema.userFiles).where(eq(schema.userFiles.id, row.id)).run(); deletedCount++; } diff --git a/apps/docs/api/ai.md b/apps/docs/api/ai.md index aace62a5..3a224274 100644 --- a/apps/docs/api/ai.md +++ b/apps/docs/api/ai.md @@ -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. diff --git a/apps/docs/guide/architecture.md b/apps/docs/guide/architecture.md index 4d5ed690..24e31088 100644 --- a/apps/docs/guide/architecture.md +++ b/apps/docs/guide/architecture.md @@ -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. diff --git a/apps/docs/guide/developer.md b/apps/docs/guide/developer.md index d7b0f8a6..8654c3d0 100644 --- a/apps/docs/guide/developer.md +++ b/apps/docs/guide/developer.md @@ -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 diff --git a/apps/web/src/lib/tool-registry.tsx b/apps/web/src/lib/tool-registry.tsx new file mode 100644 index 00000000..a77b51d6 --- /dev/null +++ b/apps/web/src/lib/tool-registry.tsx @@ -0,0 +1,304 @@ +/** + * Tool UI registry. + * + * Maps each toolId to its settings component, display mode, and capabilities. + * Adding a new tool means adding one entry here instead of editing a 750-line file. + */ +import type React from "react"; +import { lazy } from "react"; +import type { Crop } from "react-image-crop"; +import type { EraserCanvasRef } from "@/components/tools/eraser-canvas"; +import type { PreviewTransform } from "@/components/tools/rotate-settings"; + +// ── Display modes ────────────────────────────────────────────────── + +export type DisplayMode = + | "side-by-side" + | "before-after" + | "live-preview" + | "no-comparison" + | "interactive-crop" + | "interactive-eraser" + | "no-dropzone"; + +// ── Crop and eraser prop types ───────────────────────────────────── + +export interface CropProps { + cropState: { + crop: Crop; + aspect: number | undefined; + showGrid: boolean; + imgDimensions: { width: number; height: number } | null; + }; + onCropChange: (crop: Crop) => void; + onAspectChange: (aspect: number | undefined) => void; + onGridToggle: (show: boolean) => void; +} + +export interface EraserProps { + eraserRef: React.RefObject; + hasStrokes: boolean; + brushSize: number; + onBrushSizeChange: (size: number) => void; +} + +// ── Registry entry ───────────────────────────────────────────────── + +export interface ToolRegistryEntry { + /** The display mode for this tool's image viewer. */ + displayMode: DisplayMode; + /** Whether this tool supports live preview transforms (rotate, color). */ + livePreview?: boolean; + /** The settings component for this tool. */ + Settings: React.ComponentType<{ + onPreviewTransform?: (t: PreviewTransform) => void; + onPreviewFilter?: (filter: string) => void; + cropProps?: CropProps; + eraserProps?: EraserProps; + }>; +} + +// ── Lazy-loaded settings components ──────────────────────────────── +// Using dynamic imports so the bundle only loads what's needed. + +const ResizeSettings = lazy(() => + import("@/components/tools/resize-settings").then((m) => ({ default: m.ResizeSettings })), +); +const CropSettings = lazy(() => + import("@/components/tools/crop-settings").then((m) => ({ default: m.CropSettings })), +); +const RotateSettings = lazy(() => + import("@/components/tools/rotate-settings").then((m) => ({ default: m.RotateSettings })), +); +const ConvertSettings = lazy(() => + import("@/components/tools/convert-settings").then((m) => ({ default: m.ConvertSettings })), +); +const CompressSettings = lazy(() => + import("@/components/tools/compress-settings").then((m) => ({ default: m.CompressSettings })), +); +const StripMetadataSettings = lazy(() => + import("@/components/tools/strip-metadata-settings").then((m) => ({ + default: m.StripMetadataSettings, + })), +); +const ColorSettings = lazy(() => + import("@/components/tools/color-settings").then((m) => ({ default: m.ColorSettings })), +); +const WatermarkTextSettings = lazy(() => + import("@/components/tools/watermark-text-settings").then((m) => ({ + default: m.WatermarkTextSettings, + })), +); +const WatermarkImageSettings = lazy(() => + import("@/components/tools/watermark-image-settings").then((m) => ({ + default: m.WatermarkImageSettings, + })), +); +const TextOverlaySettings = lazy(() => + import("@/components/tools/text-overlay-settings").then((m) => ({ + default: m.TextOverlaySettings, + })), +); +const ComposeSettings = lazy(() => + import("@/components/tools/compose-settings").then((m) => ({ default: m.ComposeSettings })), +); +const InfoSettings = lazy(() => + import("@/components/tools/info-settings").then((m) => ({ default: m.InfoSettings })), +); +const CompareSettings = lazy(() => + import("@/components/tools/compare-settings").then((m) => ({ default: m.CompareSettings })), +); +const FindDuplicatesSettings = lazy(() => + import("@/components/tools/find-duplicates-settings").then((m) => ({ + default: m.FindDuplicatesSettings, + })), +); +const ColorPaletteSettings = lazy(() => + import("@/components/tools/color-palette-settings").then((m) => ({ + default: m.ColorPaletteSettings, + })), +); +const QrGenerateSettings = lazy(() => + import("@/components/tools/qr-generate-settings").then((m) => ({ + default: m.QrGenerateSettings, + })), +); +const BarcodeReadSettings = lazy(() => + import("@/components/tools/barcode-read-settings").then((m) => ({ + default: m.BarcodeReadSettings, + })), +); +const CollageSettings = lazy(() => + import("@/components/tools/collage-settings").then((m) => ({ default: m.CollageSettings })), +); +const SplitSettings = lazy(() => + import("@/components/tools/split-settings").then((m) => ({ default: m.SplitSettings })), +); +const BorderSettings = lazy(() => + import("@/components/tools/border-settings").then((m) => ({ default: m.BorderSettings })), +); +const SvgToRasterSettings = lazy(() => + import("@/components/tools/svg-to-raster-settings").then((m) => ({ + default: m.SvgToRasterSettings, + })), +); +const VectorizeSettings = lazy(() => + import("@/components/tools/vectorize-settings").then((m) => ({ + default: m.VectorizeSettings, + })), +); +const GifToolsSettings = lazy(() => + import("@/components/tools/gif-tools-settings").then((m) => ({ + default: m.GifToolsSettings, + })), +); +const BulkRenameSettings = lazy(() => + import("@/components/tools/bulk-rename-settings").then((m) => ({ + default: m.BulkRenameSettings, + })), +); +const FaviconSettings = lazy(() => + import("@/components/tools/favicon-settings").then((m) => ({ default: m.FaviconSettings })), +); +const ImageToPdfSettings = lazy(() => + import("@/components/tools/image-to-pdf-settings").then((m) => ({ + default: m.ImageToPdfSettings, + })), +); +const ReplaceColorSettings = lazy(() => + import("@/components/tools/replace-color-settings").then((m) => ({ + default: m.ReplaceColorSettings, + })), +); +const RemoveBgSettings = lazy(() => + import("@/components/tools/remove-bg-settings").then((m) => ({ + default: m.RemoveBgSettings, + })), +); +const UpscaleSettings = lazy(() => + import("@/components/tools/upscale-settings").then((m) => ({ default: m.UpscaleSettings })), +); +const OcrSettings = lazy(() => + import("@/components/tools/ocr-settings").then((m) => ({ default: m.OcrSettings })), +); +const BlurFacesSettings = lazy(() => + import("@/components/tools/blur-faces-settings").then((m) => ({ + default: m.BlurFacesSettings, + })), +); +const EraseObjectSettings = lazy(() => + import("@/components/tools/erase-object-settings").then((m) => ({ + default: m.EraseObjectSettings, + })), +); +const SmartCropSettings = lazy(() => + import("@/components/tools/smart-crop-settings").then((m) => ({ + default: m.SmartCropSettings, + })), +); + +// ── Color tool wrapper ───────────────────────────────────────────── +// Color tools share a single component but differ by toolId. + +function makeColorSettingsComponent( + toolId: string, +): React.ComponentType<{ onPreviewFilter?: (filter: string) => void }> { + return function ColorSettingsForTool(props: { onPreviewFilter?: (filter: string) => void }) { + return ; + }; +} + +// ── Crop/Eraser wrappers ─────────────────────────────────────────── +// These tools need special props that are passed through the registry. + +function CropSettingsWrapper(props: { cropProps?: CropProps }) { + if (!props.cropProps) return null; + return ; +} + +function EraseObjectSettingsWrapper(props: { eraserProps?: EraserProps }) { + if (!props.eraserProps) return null; + return ; +} + +// ── The registry ─────────────────────────────────────────────────── + +export const toolRegistry = new Map([ + // Essentials + ["resize", { displayMode: "side-by-side", Settings: ResizeSettings }], + ["crop", { displayMode: "interactive-crop", Settings: CropSettingsWrapper as never }], + [ + "rotate", + { + displayMode: "side-by-side", + livePreview: true, + Settings: RotateSettings as never, + }, + ], + ["convert", { displayMode: "no-comparison", Settings: ConvertSettings }], + ["compress", { displayMode: "before-after", Settings: CompressSettings }], + ["strip-metadata", { displayMode: "no-comparison", Settings: StripMetadataSettings }], + + // Color adjustments (all share ColorSettings with different toolId) + ...(["brightness-contrast", "saturation", "color-channels", "color-effects"] as const).map( + (id) => + [ + id, + { + displayMode: "live-preview" as DisplayMode, + livePreview: true, + Settings: makeColorSettingsComponent(id) as never, + }, + ] as const, + ), + + // Watermark & Overlay + ["watermark-text", { displayMode: "before-after", Settings: WatermarkTextSettings }], + ["watermark-image", { displayMode: "before-after", Settings: WatermarkImageSettings }], + ["text-overlay", { displayMode: "before-after", Settings: TextOverlaySettings }], + ["compose", { displayMode: "before-after", Settings: ComposeSettings }], + + // Utilities + ["info", { displayMode: "before-after", Settings: InfoSettings }], + ["compare", { displayMode: "before-after", Settings: CompareSettings }], + ["find-duplicates", { displayMode: "before-after", Settings: FindDuplicatesSettings }], + ["color-palette", { displayMode: "before-after", Settings: ColorPaletteSettings }], + ["qr-generate", { displayMode: "no-dropzone", Settings: QrGenerateSettings }], + ["barcode-read", { displayMode: "before-after", Settings: BarcodeReadSettings }], + + // Layout & Composition + ["collage", { displayMode: "before-after", Settings: CollageSettings }], + ["split", { displayMode: "before-after", Settings: SplitSettings }], + ["border", { displayMode: "before-after", Settings: BorderSettings }], + + // Format & Conversion + ["svg-to-raster", { displayMode: "before-after", Settings: SvgToRasterSettings }], + ["vectorize", { displayMode: "before-after", Settings: VectorizeSettings }], + ["gif-tools", { displayMode: "before-after", Settings: GifToolsSettings }], + + // Optimization extras + ["bulk-rename", { displayMode: "before-after", Settings: BulkRenameSettings }], + ["favicon", { displayMode: "before-after", Settings: FaviconSettings }], + ["image-to-pdf", { displayMode: "before-after", Settings: ImageToPdfSettings }], + + // Adjustments extra + ["replace-color", { displayMode: "before-after", Settings: ReplaceColorSettings }], + + // AI Tools + ["remove-background", { displayMode: "before-after", Settings: RemoveBgSettings }], + ["upscale", { displayMode: "before-after", Settings: UpscaleSettings }], + ["ocr", { displayMode: "before-after", Settings: OcrSettings }], + ["blur-faces", { displayMode: "before-after", Settings: BlurFacesSettings }], + [ + "erase-object", + { + displayMode: "interactive-eraser", + Settings: EraseObjectSettingsWrapper as never, + }, + ], + ["smart-crop", { displayMode: "before-after", Settings: SmartCropSettings }], +]); + +export function getToolRegistryEntry(toolId: string): ToolRegistryEntry | undefined { + return toolRegistry.get(toolId); +} diff --git a/apps/web/src/pages/tool-page.tsx b/apps/web/src/pages/tool-page.tsx index 7ddfb3a7..309f8b2e 100644 --- a/apps/web/src/pages/tool-page.tsx +++ b/apps/web/src/pages/tool-page.tsx @@ -1,7 +1,7 @@ import { TOOLS } from "@stirling-image/shared"; import * as icons from "lucide-react"; import { CheckCircle2, ChevronLeft, ChevronRight, Download } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { Crop } from "react-image-crop"; import { useParams } from "react-router-dom"; import { BeforeAfterSlider } from "@/components/common/before-after-slider"; @@ -11,151 +11,15 @@ import { ReviewPanel } from "@/components/common/review-panel"; import { SideBySideComparison } from "@/components/common/side-by-side-comparison"; import { ThumbnailStrip } from "@/components/common/thumbnail-strip"; import { AppLayout } from "@/components/layout/app-layout"; -import { BarcodeReadSettings } from "@/components/tools/barcode-read-settings"; -import { BlurFacesSettings } from "@/components/tools/blur-faces-settings"; -import { BorderSettings } from "@/components/tools/border-settings"; -// Phase 3: Optimization extras -import { BulkRenameSettings } from "@/components/tools/bulk-rename-settings"; -// Phase 3: Layout & Composition -import { CollageSettings } from "@/components/tools/collage-settings"; -import { ColorPaletteSettings } from "@/components/tools/color-palette-settings"; -import { ColorSettings } from "@/components/tools/color-settings"; -import { CompareSettings } from "@/components/tools/compare-settings"; -import { ComposeSettings } from "@/components/tools/compose-settings"; -import { CompressSettings } from "@/components/tools/compress-settings"; -import { ConvertSettings } from "@/components/tools/convert-settings"; import { CropCanvas } from "@/components/tools/crop-canvas"; -import { CropSettings } from "@/components/tools/crop-settings"; -import { EraseObjectSettings } from "@/components/tools/erase-object-settings"; import type { EraserCanvasRef } from "@/components/tools/eraser-canvas"; import { EraserCanvas } from "@/components/tools/eraser-canvas"; -import { FaviconSettings } from "@/components/tools/favicon-settings"; -import { FindDuplicatesSettings } from "@/components/tools/find-duplicates-settings"; -import { GifToolsSettings } from "@/components/tools/gif-tools-settings"; -import { ImageToPdfSettings } from "@/components/tools/image-to-pdf-settings"; -// Phase 3: Utilities -import { InfoSettings } from "@/components/tools/info-settings"; -import { OcrSettings } from "@/components/tools/ocr-settings"; -import { QrGenerateSettings } from "@/components/tools/qr-generate-settings"; -// Phase 4: AI Tools -import { RemoveBgSettings } from "@/components/tools/remove-bg-settings"; -// Phase 3: Adjustments extra -import { ReplaceColorSettings } from "@/components/tools/replace-color-settings"; -import { ResizeSettings } from "@/components/tools/resize-settings"; import type { PreviewTransform } from "@/components/tools/rotate-settings"; -import { RotateSettings } from "@/components/tools/rotate-settings"; -import { SmartCropSettings } from "@/components/tools/smart-crop-settings"; -import { SplitSettings } from "@/components/tools/split-settings"; -import { StripMetadataSettings } from "@/components/tools/strip-metadata-settings"; -// Phase 3: Format & Conversion -import { SvgToRasterSettings } from "@/components/tools/svg-to-raster-settings"; -import { TextOverlaySettings } from "@/components/tools/text-overlay-settings"; -import { UpscaleSettings } from "@/components/tools/upscale-settings"; -import { VectorizeSettings } from "@/components/tools/vectorize-settings"; -import { WatermarkImageSettings } from "@/components/tools/watermark-image-settings"; -// Phase 3: Watermark & Overlay -import { WatermarkTextSettings } from "@/components/tools/watermark-text-settings"; import { useMobile } from "@/hooks/use-mobile"; import { formatFileSize } from "@/lib/download"; +import { getToolRegistryEntry } from "@/lib/tool-registry"; import { useFileStore } from "@/stores/file-store"; -const COLOR_TOOL_IDS = new Set([ - "brightness-contrast", - "saturation", - "color-channels", - "color-effects", -]); - -// Tools that don't need a file dropzone (they generate content or have custom UI) -const NO_DROPZONE_TOOLS = new Set(["qr-generate"]); -const SIDE_BY_SIDE_TOOLS = new Set(["resize", "crop", "rotate", "erase-object"]); -const LIVE_PREVIEW_TOOLS = new Set([ - "rotate", - "brightness-contrast", - "saturation", - "color-channels", - "color-effects", -]); -const NO_COMPARISON_TOOLS = new Set(["strip-metadata", "convert"]); -const INTERACTIVE_CROP_TOOLS = new Set(["crop"]); -const INTERACTIVE_ERASER_TOOLS = new Set(["erase-object"]); - -function ToolSettingsPanel({ - toolId, - onPreviewTransform, - onPreviewFilter, - cropProps, - eraserProps, -}: { - toolId: string; - onPreviewTransform?: (t: PreviewTransform) => void; - onPreviewFilter?: (filter: string) => void; - cropProps?: { - cropState: { - crop: Crop; - aspect: number | undefined; - showGrid: boolean; - imgDimensions: { width: number; height: number } | null; - }; - onCropChange: (crop: Crop) => void; - onAspectChange: (aspect: number | undefined) => void; - onGridToggle: (show: boolean) => void; - }; - eraserProps?: { - eraserRef: React.RefObject; - hasStrokes: boolean; - brushSize: number; - onBrushSizeChange: (size: number) => void; - }; -}) { - // Phase 2: Core tools - if (toolId === "resize") return ; - if (toolId === "crop" && cropProps) return ; - if (toolId === "rotate") return ; - if (toolId === "convert") return ; - if (toolId === "compress") return ; - if (toolId === "strip-metadata") return ; - if (COLOR_TOOL_IDS.has(toolId)) - return ; - // Phase 3: Watermark & Overlay - if (toolId === "watermark-text") return ; - if (toolId === "watermark-image") return ; - if (toolId === "text-overlay") return ; - if (toolId === "compose") return ; - // Phase 3: Utilities - if (toolId === "info") return ; - if (toolId === "compare") return ; - if (toolId === "find-duplicates") return ; - if (toolId === "color-palette") return ; - if (toolId === "qr-generate") return ; - if (toolId === "barcode-read") return ; - // Phase 3: Layout & Composition - if (toolId === "collage") return ; - if (toolId === "split") return ; - if (toolId === "border") return ; - // Phase 3: Format & Conversion - if (toolId === "svg-to-raster") return ; - if (toolId === "vectorize") return ; - if (toolId === "gif-tools") return ; - // Phase 3: Optimization extras - if (toolId === "bulk-rename") return ; - if (toolId === "favicon") return ; - if (toolId === "image-to-pdf") return ; - // Phase 3: Adjustments extra - if (toolId === "replace-color") return ; - // Phase 4: AI Tools - if (toolId === "remove-background") return ; - if (toolId === "upscale") return ; - if (toolId === "ocr") return ; - if (toolId === "blur-faces") return ; - if (toolId === "erase-object" && eraserProps) return ; - if (toolId === "smart-crop") return ; - - return ( -

Settings for this tool are coming soon.

- ); -} - /** File selection indicator shown in left panel */ function FileSelectionInfo({ files, @@ -209,6 +73,10 @@ function FileSelectionInfo({ export function ToolPage() { const { toolId } = useParams<{ toolId: string }>(); const tool = useMemo(() => TOOLS.find((t) => t.id === toolId), [toolId]); + const registryEntry = useMemo( + () => (toolId ? getToolRegistryEntry(toolId) : undefined), + [toolId], + ); const { files, entries, @@ -319,7 +187,7 @@ export function ToolPage() { URL.revokeObjectURL(url); }, [batchZipBlob, batchZipFilename]); - if (!tool) { + if (!tool || !registryEntry) { return (
@@ -335,7 +203,9 @@ export function ToolPage() { const hasFile = files.length > 0; const hasProcessed = !!processedUrl; - const isNoDropzone = NO_DROPZONE_TOOLS.has(tool.id); + const displayMode = registryEntry.displayMode; + const isNoDropzone = displayMode === "no-dropzone"; + const isLivePreview = registryEntry.livePreview ?? false; // Derive processed file info from context const processedFileName = selectedFileName ? `processed-${selectedFileName}` : "processed-image"; @@ -343,6 +213,198 @@ export function ToolPage() { ? selectedFileName.split(".").pop()?.toUpperCase() || "IMAGE" : "IMAGE"; + // Build settings props + const settingsProps = { + onPreviewTransform: isLivePreview ? setPreviewTransform : undefined, + onPreviewFilter: isLivePreview ? setPreviewFilter : undefined, + cropProps: + displayMode === "interactive-crop" + ? { + cropState, + onCropChange: setCropCrop, + onAspectChange: setCropAspect, + onGridToggle: setCropShowGrid, + } + : undefined, + eraserProps: + displayMode === "interactive-eraser" + ? { + eraserRef, + hasStrokes: eraserHasStrokes, + brushSize: eraserBrushSize, + onBrushSizeChange: setEraserBrushSize, + } + : undefined, + }; + + const ToolSettings = registryEntry.Settings; + + // Render the image viewer based on display mode + function renderImageArea() { + if (isNoDropzone) { + return ( +
+

Configure settings and generate.

+
+ ); + } + + if (displayMode === "interactive-crop" && hasFile && !hasProcessed && originalBlobUrl) { + return ( + + ); + } + + if (displayMode === "interactive-eraser" && hasFile && !hasProcessed && originalBlobUrl) { + return ( + + ); + } + + if (hasProcessed && originalBlobUrl && displayMode === "side-by-side") { + return ( + + ); + } + + if ( + hasProcessed && + originalBlobUrl && + (displayMode === "live-preview" || displayMode === "no-comparison") + ) { + return ( + + ); + } + + if (hasProcessed && originalBlobUrl) { + return ( + + ); + } + + if (hasFile && originalBlobUrl) { + return ( + + ); + } + + return ; + } + + // Navigation arrows (shared between mobile/desktop) + function renderNavArrows() { + return ( + <> + {hasMultiple && hasPrev && ( + + )} + {hasMultiple && hasNext && ( + + )} + {hasMultiple && ( +
+ {selectedIndex + 1} / {entries.length} +
+ )} + + ); + } + + // Render the settings panel content (shared between mobile/desktop) + function renderSettingsContent() { + return ( + <> + {!isNoDropzone && ( +
+

Files

+ +
+ )} + +
+ +
+

Settings

+ Loading...
}> + + +
+ + {hasProcessed && processedSize != null && ( + + )} + + ); + } + // Mobile layout: settings above dropzone (stacked) if (isMobile) { return ( @@ -366,69 +428,11 @@ export function ToolPage() { {/* Collapsible settings */} {mobileSettingsOpen && (
- {/* File info */} - {!isNoDropzone && ( -
-

Files

- -
- )} - -
- -
-

Settings

- -
- - {/* Review panel (mobile) */} - {hasProcessed && processedSize != null && ( - - )} + {renderSettingsContent()}
)} - {/* Main area: Dropzone / Image Viewer / Before-After */} + {/* Main area: image viewer */}
- {hasMultiple && hasPrev && ( - - )} - {isNoDropzone ? ( -
-

Configure settings and generate.

-
- ) : INTERACTIVE_CROP_TOOLS.has(tool.id) && - hasFile && - !hasProcessed && - originalBlobUrl ? ( - - ) : INTERACTIVE_ERASER_TOOLS.has(tool.id) && - hasFile && - !hasProcessed && - originalBlobUrl ? ( - - ) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? ( - - ) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? ( - - ) : hasProcessed && originalBlobUrl && NO_COMPARISON_TOOLS.has(tool.id) ? ( - - ) : hasProcessed && originalBlobUrl ? ( - - ) : hasFile && originalBlobUrl ? ( - - ) : ( - - )} - {hasMultiple && hasNext && ( - - )} - {hasMultiple && ( -
- {selectedIndex + 1} / {entries.length} -
- )} + {renderNavArrows()} + {renderImageArea()}
{hasMultiple && ( {tool.name}
- {/* File info - hidden for tools that don't need files */} - {!isNoDropzone && ( -
-

Files

- -
- )} - -
- - {/* Tool-specific settings */} -
-

Settings

- -
- - {/* Review panel (desktop - below settings) */} - {hasProcessed && processedSize != null && ( - - )} + {renderSettingsContent()} {/* Batch download */} {entries.length > 1 && hasProcessed && batchZipBlob && ( @@ -635,7 +487,7 @@ export function ToolPage() { )}
- {/* Main area: Dropzone / Image Viewer / Before-After */} + {/* Main area: image viewer */}
- {hasMultiple && hasPrev && ( - - )} - {isNoDropzone ? ( -
-

Configure settings and generate.

-
- ) : INTERACTIVE_CROP_TOOLS.has(tool.id) && - hasFile && - !hasProcessed && - originalBlobUrl ? ( - - ) : INTERACTIVE_ERASER_TOOLS.has(tool.id) && - hasFile && - !hasProcessed && - originalBlobUrl ? ( - - ) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? ( - - ) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? ( - - ) : hasProcessed && originalBlobUrl && NO_COMPARISON_TOOLS.has(tool.id) ? ( - - ) : hasProcessed && originalBlobUrl ? ( - - ) : hasFile && originalBlobUrl ? ( - - ) : ( - - )} - {hasMultiple && hasNext && ( - - )} - {hasMultiple && ( -
- {selectedIndex + 1} / {entries.length} -
- )} + {renderNavArrows()} + {renderImageArea()}
{hasMultiple && ( e.file === prevFiles[i])) { + return prevFiles; + } + prevFiles = entries.map((e) => e.file); + return prevFiles; +} + // --------------------------------------------------------------------------- // Store // --------------------------------------------------------------------------- @@ -47,11 +81,9 @@ interface FileState { processing: boolean; error: string | null; - // Backward compat getters (computed from entries + selectedIndex) + // Derived from entries (selected entry fields) readonly files: File[]; readonly currentEntry: FileEntry | undefined; - readonly hasFiles: boolean; - readonly allProcessed: boolean; readonly selectedFileName: string | null; readonly selectedFileSize: number | null; readonly originalBlobUrl: string | null; @@ -77,26 +109,6 @@ interface FileState { reset: () => void; } -/** - * Compute backward-compat derived values from core state. - * Called after every state mutation to keep derived fields in sync. - */ -function deriveCompat(entries: FileEntry[], selectedIndex: number) { - const entry = entries[selectedIndex]; - return { - files: entries.map((e) => e.file), - currentEntry: entry, - hasFiles: entries.length > 0, - allProcessed: entries.length > 0 && entries.every((e) => e.status === "completed"), - selectedFileName: entry ? entry.file.name : null, - selectedFileSize: entry ? entry.file.size : null, - originalBlobUrl: entry ? entry.blobUrl : null, - processedUrl: entry ? entry.processedUrl : null, - originalSize: entry ? entry.originalSize : null, - processedSize: entry ? entry.processedSize : null, - }; -} - export const useFileStore = create((set, get) => ({ entries: [], selectedIndex: 0, @@ -106,7 +118,8 @@ export const useFileStore = create((set, get) => ({ error: null, // Initial derived values (empty state) - ...deriveCompat([], 0), + files: [], + ...deriveSelected([], 0), // -- Actions -------------------------------------------------------------- @@ -117,14 +130,15 @@ export const useFileStore = create((set, get) => ({ entries, selectedIndex: 0, error: null, - ...deriveCompat(entries, 0), + files: deriveFiles(entries), + ...deriveSelected(entries, 0), }); }, addFiles: (files) => { const entries = [...get().entries, ...files.map(createEntry)]; const idx = get().selectedIndex; - set({ entries, ...deriveCompat(entries, idx) }); + set({ entries, files: deriveFiles(entries), ...deriveSelected(entries, idx) }); }, removeFile: (index) => { @@ -147,14 +161,15 @@ export const useFileStore = create((set, get) => ({ set({ entries: newEntries, selectedIndex: newIndex, - ...deriveCompat(newEntries, newIndex), + files: deriveFiles(newEntries), + ...deriveSelected(newEntries, newIndex), }); }, setSelectedIndex: (index) => { set({ selectedIndex: index, - ...deriveCompat(get().entries, index), + ...deriveSelected(get().entries, index), }); }, @@ -162,7 +177,7 @@ export const useFileStore = create((set, get) => ({ const { selectedIndex, entries } = get(); if (selectedIndex < entries.length - 1) { const idx = selectedIndex + 1; - set({ selectedIndex: idx, ...deriveCompat(entries, idx) }); + set({ selectedIndex: idx, ...deriveSelected(entries, idx) }); } }, @@ -170,7 +185,7 @@ export const useFileStore = create((set, get) => ({ const { selectedIndex, entries } = get(); if (selectedIndex > 0) { const idx = selectedIndex - 1; - set({ selectedIndex: idx, ...deriveCompat(entries, idx) }); + set({ selectedIndex: idx, ...deriveSelected(entries, idx) }); } }, @@ -179,7 +194,7 @@ export const useFileStore = create((set, get) => ({ if (!entries[index]) return; entries[index] = { ...entries[index], ...patch }; const idx = get().selectedIndex; - set({ entries, ...deriveCompat(entries, idx) }); + set({ entries, files: deriveFiles(entries), ...deriveSelected(entries, idx) }); }, setBatchZip: (blob, filename) => set({ batchZipBlob: blob, batchZipFilename: filename }), @@ -209,7 +224,7 @@ export const useFileStore = create((set, get) => ({ status: "pending", }; } - set({ entries: updated, ...deriveCompat(updated, selectedIndex) }); + set({ entries: updated, ...deriveSelected(updated, selectedIndex) }); }, setSizes: (original, processed) => { @@ -221,7 +236,7 @@ export const useFileStore = create((set, get) => ({ originalSize: original, processedSize: processed, }; - set({ entries: updated, ...deriveCompat(updated, selectedIndex) }); + set({ entries: updated, ...deriveSelected(updated, selectedIndex) }); }, undoProcessing: () => { @@ -239,12 +254,14 @@ export const useFileStore = create((set, get) => ({ set({ entries: resetEntries, error: null, - ...deriveCompat(resetEntries, selectedIndex), + files: deriveFiles(resetEntries), + ...deriveSelected(resetEntries, selectedIndex), }); }, reset: () => { revokeEntries(get().entries); + prevFiles = []; set({ entries: [], selectedIndex: 0, @@ -252,7 +269,8 @@ export const useFileStore = create((set, get) => ({ batchZipFilename: null, processing: false, error: null, - ...deriveCompat([], 0), + files: [], + ...deriveSelected([], 0), }); }, })); diff --git a/packages/ai/python/dispatcher.py b/packages/ai/python/dispatcher.py new file mode 100644 index 00000000..ce6d3861 --- /dev/null +++ b/packages/ai/python/dispatcher.py @@ -0,0 +1,163 @@ +""" +Persistent Python sidecar dispatcher. + +Runs as a long-lived process. Reads JSON requests from stdin (one per line), +dispatches to the appropriate AI handler, writes JSON responses to stdout. +Progress emissions continue via stderr (unchanged from the standalone scripts). + +Request format: {"id": "uuid", "script": "remove_bg", "args": [...]} +Response format: {"id": "uuid", "stdout": "...", "exitCode": 0} + +Pre-imports heavy libraries at startup to eliminate cold-start latency. +""" +import sys +import json +import io +import os +import traceback + + +def emit_progress(percent, stage): + """Emit structured progress to stderr.""" + print(json.dumps({"progress": percent, "stage": stage}), file=sys.stderr, flush=True) + + +# ── Pre-import heavy libraries ────────────────────────────────────── +# These imports are the main source of cold-start latency. +# By importing once at startup, subsequent requests skip the import cost. + +available_modules = {} + + +def _try_import(name, import_fn): + try: + available_modules[name] = import_fn() + except ImportError: + pass + + +_try_import("PIL", lambda: __import__("PIL")) +_try_import("cv2", lambda: __import__("cv2")) +_try_import("numpy", lambda: __import__("numpy")) + +# Heavy ML libraries - import but don't fail if unavailable +_try_import("rembg", lambda: __import__("rembg")) + + +# ── Script handlers ───────────────────────────────────────────────── +# Each handler sets sys.argv and calls the script's main() function, +# capturing stdout. The scripts remain unchanged. + + +def _run_script_main(script_name, args): + """ + Import and run a script's main() function, capturing its stdout output. + + Since some scripts (like remove_bg.py) manipulate file descriptors directly + (os.dup2), we use a pipe at the fd level rather than StringIO. + """ + script_dir = os.path.dirname(os.path.abspath(__file__)) + + # Save original state + old_argv = sys.argv + + # Create a pipe to capture stdout at the fd level + read_fd, write_fd = os.pipe() + + # Save the real stdout fd + real_stdout_fd = os.dup(1) + + # Redirect fd 1 to our pipe's write end + os.dup2(write_fd, 1) + os.close(write_fd) + + # Also redirect sys.stdout to the same fd + old_sys_stdout = sys.stdout + sys.stdout = os.fdopen(1, "w", closefd=False) + + exit_code = 0 + try: + sys.argv = ["script.py"] + args + + # Load and run the script + script_path = os.path.join(script_dir, script_name + ".py") + + module_globals = {"__name__": "__main__", "__file__": script_path} + + with open(script_path) as f: + code = compile(f.read(), script_path, "exec") + + # Run the compiled script in its own namespace + exec(code, module_globals) # noqa: S102 - trusted internal scripts only + + except SystemExit as e: + exit_code = e.code if isinstance(e.code, int) else 1 + except Exception as e: + # Write error to the captured stdout + sys.stdout.write(json.dumps({"success": False, "error": str(e)}) + "\n") + sys.stdout.flush() + exit_code = 1 + finally: + # Flush before restoring + sys.stdout.flush() + + # Restore stdout fd + os.dup2(real_stdout_fd, 1) + os.close(real_stdout_fd) + + # Restore sys.stdout + sys.stdout = old_sys_stdout + + # Restore sys.argv + sys.argv = old_argv + + # Read captured output from the pipe + read_file = os.fdopen(read_fd, "r") + captured = read_file.read() + read_file.close() + + return captured.strip(), exit_code + + +# ── Main loop ─────────────────────────────────────────────────────── + + +def main(): + # Signal readiness + print(json.dumps({"ready": True}), file=sys.stderr, flush=True) + + for line in sys.stdin: + line = line.strip() + if not line: + continue + + try: + request = json.loads(line) + except json.JSONDecodeError: + continue + + request_id = request.get("id", "unknown") + script_name = request.get("script", "") + args = request.get("args", []) + + try: + stdout_output, exit_code = _run_script_main(script_name, args) + response = { + "id": request_id, + "stdout": stdout_output, + "exitCode": exit_code, + } + except Exception as e: + response = { + "id": request_id, + "stdout": json.dumps({"success": False, "error": str(e)}), + "exitCode": 1, + } + + # Write response as a single JSON line to stdout + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/packages/ai/src/bridge.ts b/packages/ai/src/bridge.ts index da34c60f..a08ec503 100644 --- a/packages/ai/src/bridge.ts +++ b/packages/ai/src/bridge.ts @@ -1,4 +1,5 @@ -import { spawn } from "node:child_process"; +import { type ChildProcess, spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -41,16 +42,200 @@ function extractPythonError(error: unknown): string { export type ProgressCallback = (percent: number, stage: string) => void; +// ── Persistent dispatcher ─────────────────────────────────────────── + +interface PendingRequest { + resolve: (result: { stdout: string; stderr: string }) => void; + reject: (err: Error) => void; + onProgress?: ProgressCallback; + stderrLines: string[]; +} + +let dispatcher: ChildProcess | null = null; +let dispatcherReady = false; +let dispatcherFailed = false; +const pendingRequests = new Map(); +let stdoutBuffer = ""; + +function startDispatcher(): ChildProcess | null { + if (dispatcherFailed) return null; + + try { + const child = spawn(getPythonPath(), [resolve(PYTHON_DIR, "dispatcher.py")], { + stdio: ["pipe", "pipe", "pipe"], + }); + + let stderrBuffer = ""; + + child.stderr?.on("data", (chunk: Buffer) => { + stderrBuffer += chunk.toString(); + const lines = stderrBuffer.split("\n"); + stderrBuffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + try { + const parsed = JSON.parse(trimmed); + + // Readiness signal + if (parsed.ready === true) { + dispatcherReady = true; + continue; + } + + // Progress event - route to the currently active request + if (typeof parsed.progress === "number" && typeof parsed.stage === "string") { + // Progress goes to all pending requests (only one should be active at a time + // since Python processes synchronously) + for (const req of pendingRequests.values()) { + req.onProgress?.(parsed.progress, parsed.stage); + } + } + } catch { + // Not JSON - collect as error output for pending requests + for (const req of pendingRequests.values()) { + req.stderrLines.push(trimmed); + } + } + } + }); + + child.stdout?.on("data", (chunk: Buffer) => { + stdoutBuffer += chunk.toString(); + const lines = stdoutBuffer.split("\n"); + stdoutBuffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + try { + const response = JSON.parse(trimmed); + const reqId = response.id; + const pending = pendingRequests.get(reqId); + if (pending) { + pendingRequests.delete(reqId); + if (response.exitCode !== 0) { + pending.reject( + new Error( + extractPythonError({ + stdout: response.stdout, + stderr: pending.stderrLines.join("\n"), + }) || `Python script exited with code ${response.exitCode}`, + ), + ); + } else { + pending.resolve({ + stdout: response.stdout || "", + stderr: pending.stderrLines.join("\n"), + }); + } + } + } catch { + // Not a valid response line + } + } + }); + + child.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "ENOENT") { + // Venv python not found - mark as failed, will fall back to per-request + dispatcherFailed = true; + } + // Reject all pending requests + for (const [id, req] of pendingRequests.entries()) { + req.reject(new Error(extractPythonError(err))); + pendingRequests.delete(id); + } + dispatcher = null; + dispatcherReady = false; + }); + + child.on("close", () => { + // Reject all pending requests + for (const [id, req] of pendingRequests.entries()) { + req.reject(new Error("Python dispatcher exited unexpectedly")); + pendingRequests.delete(id); + } + dispatcher = null; + dispatcherReady = false; + }); + + return child; + } catch { + dispatcherFailed = true; + return null; + } +} + +function getDispatcher(): ChildProcess | null { + if (dispatcherFailed) return null; + if (!dispatcher || dispatcher.killed) { + dispatcher = startDispatcher(); + } + return dispatcher; +} + /** - * Run a Python script with real-time progress streaming via stderr. - * Falls back to system python3 if the venv is not available. - * - * Python scripts emit progress as JSON lines to stderr: - * {"progress": 50, "stage": "Processing..."} - * - * Non-JSON stderr lines are collected as error output (backward compatible). + * Send a request to the persistent Python dispatcher. + * Returns null if the dispatcher is unavailable (caller should fall back). */ -export function runPythonWithProgress( +function dispatcherRun( + scriptName: string, + args: string[], + options: { onProgress?: ProgressCallback; timeout?: number } = {}, +): Promise<{ stdout: string; stderr: string }> | null { + const proc = getDispatcher(); + if (!proc || !proc.stdin || !dispatcherReady) return null; + + const id = randomUUID(); + const timeout = options.timeout ?? 300000; + + return new Promise((resolvePromise, rejectPromise) => { + const timer = setTimeout(() => { + pendingRequests.delete(id); + rejectPromise(new Error("Python script timed out")); + }, timeout); + + const wrappedResolve = (result: { stdout: string; stderr: string }) => { + clearTimeout(timer); + resolvePromise(result); + }; + + const wrappedReject = (err: Error) => { + clearTimeout(timer); + rejectPromise(err); + }; + + pendingRequests.set(id, { + resolve: wrappedResolve, + reject: wrappedReject, + onProgress: options.onProgress, + stderrLines: [], + }); + + const request = JSON.stringify({ id, script: scriptName.replace(".py", ""), args }); + proc.stdin!.write(request + "\n"); + }); +} + +/** + * Shut down the persistent dispatcher process. + */ +export function shutdownDispatcher(): void { + if (dispatcher && !dispatcher.killed) { + dispatcher.stdin?.end(); + dispatcher.kill("SIGTERM"); + dispatcher = null; + dispatcherReady = false; + } +} + +// ── Per-request fallback (original implementation) ────────────────── + +function runPythonPerRequest( scriptName: string, args: string[], options: { @@ -97,7 +282,7 @@ export function runPythonWithProgress( continue; } } catch { - // Not JSON — collect as regular stderr + // Not JSON - collect as regular stderr } stderrLines.push(trimmed); } @@ -141,3 +326,29 @@ export function runPythonWithProgress( trySpawn(getPythonPath(), false); }); } + +// ── Public API (unchanged signature) ──────────────────────────────── + +/** + * Run a Python script with real-time progress streaming via stderr. + * + * Tries the persistent dispatcher first for warm-start performance. + * Falls back to per-request spawning if the dispatcher is unavailable. + */ +export function runPythonWithProgress( + scriptName: string, + args: string[], + options: { + onProgress?: ProgressCallback; + timeout?: number; + } = {}, +): Promise<{ stdout: string; stderr: string }> { + // Try persistent dispatcher first + const dispatcherPromise = dispatcherRun(scriptName, args, options); + if (dispatcherPromise) { + return dispatcherPromise; + } + + // Fall back to per-request spawning + return runPythonPerRequest(scriptName, args, options); +} diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index bed4f00b..6bfbd601 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -1,4 +1,5 @@ export { removeBackground } from "./background-removal.js"; +export { shutdownDispatcher } from "./bridge.js"; export { blurFaces } from "./face-detection.js"; export { inpaint } from "./inpainting.js"; export { extractText } from "./ocr.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78d89649..7b87ec56 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,6 +125,9 @@ importers: pdfkit: specifier: ^0.18.0 version: 0.18.0 + piscina: + specifier: ^5.1.4 + version: 5.1.4 potrace: specifier: ^2.1.8 version: 2.1.8 @@ -1848,6 +1851,112 @@ packages: resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} + '@napi-rs/nice-android-arm-eabi@1.1.1': + resolution: {integrity: sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@napi-rs/nice-android-arm64@1.1.1': + resolution: {integrity: sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/nice-darwin-arm64@1.1.1': + resolution: {integrity: sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/nice-darwin-x64@1.1.1': + resolution: {integrity: sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/nice-freebsd-x64@1.1.1': + resolution: {integrity: sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/nice-linux-arm-gnueabihf@1.1.1': + resolution: {integrity: sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/nice-linux-arm64-gnu@1.1.1': + resolution: {integrity: sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/nice-linux-arm64-musl@1.1.1': + resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/nice-linux-ppc64-gnu@1.1.1': + resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==} + engines: {node: '>= 10'} + cpu: [ppc64] + os: [linux] + + '@napi-rs/nice-linux-riscv64-gnu@1.1.1': + resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/nice-linux-s390x-gnu@1.1.1': + resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==} + engines: {node: '>= 10'} + cpu: [s390x] + os: [linux] + + '@napi-rs/nice-linux-x64-gnu@1.1.1': + resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/nice-linux-x64-musl@1.1.1': + resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/nice-openharmony-arm64@1.1.1': + resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [openharmony] + + '@napi-rs/nice-win32-arm64-msvc@1.1.1': + resolution: {integrity: sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/nice-win32-ia32-msvc@1.1.1': + resolution: {integrity: sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/nice-win32-x64-msvc@1.1.1': + resolution: {integrity: sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/nice@1.1.1': + resolution: {integrity: sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==} + engines: {node: '>= 10'} + '@noble/ciphers@1.3.0': resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} @@ -4469,6 +4578,10 @@ packages: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true + piscina@5.1.4: + resolution: {integrity: sha512-7uU4ZnKeQq22t9AsmHGD2w4OYQGonwFnTypDypaWi7Qr2EvQIFVtG8J5D/3bE7W123Wdc9+v4CZDu5hJXVCtBg==} + engines: {node: '>=20.x'} + pixelmatch@4.0.2: resolution: {integrity: sha512-J8B6xqiO37sU/gkcMglv6h5Jbd9xNER7aHzpfRdNmV4IbQBzBpe4l9XmbG+xPF/znacgu2jfEw+wHffaq/YkXA==} hasBin: true @@ -6933,6 +7046,78 @@ snapshots: '@lukeed/ms@2.0.2': {} + '@napi-rs/nice-android-arm-eabi@1.1.1': + optional: true + + '@napi-rs/nice-android-arm64@1.1.1': + optional: true + + '@napi-rs/nice-darwin-arm64@1.1.1': + optional: true + + '@napi-rs/nice-darwin-x64@1.1.1': + optional: true + + '@napi-rs/nice-freebsd-x64@1.1.1': + optional: true + + '@napi-rs/nice-linux-arm-gnueabihf@1.1.1': + optional: true + + '@napi-rs/nice-linux-arm64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-arm64-musl@1.1.1': + optional: true + + '@napi-rs/nice-linux-ppc64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-riscv64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-s390x-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-x64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-x64-musl@1.1.1': + optional: true + + '@napi-rs/nice-openharmony-arm64@1.1.1': + optional: true + + '@napi-rs/nice-win32-arm64-msvc@1.1.1': + optional: true + + '@napi-rs/nice-win32-ia32-msvc@1.1.1': + optional: true + + '@napi-rs/nice-win32-x64-msvc@1.1.1': + optional: true + + '@napi-rs/nice@1.1.1': + optionalDependencies: + '@napi-rs/nice-android-arm-eabi': 1.1.1 + '@napi-rs/nice-android-arm64': 1.1.1 + '@napi-rs/nice-darwin-arm64': 1.1.1 + '@napi-rs/nice-darwin-x64': 1.1.1 + '@napi-rs/nice-freebsd-x64': 1.1.1 + '@napi-rs/nice-linux-arm-gnueabihf': 1.1.1 + '@napi-rs/nice-linux-arm64-gnu': 1.1.1 + '@napi-rs/nice-linux-arm64-musl': 1.1.1 + '@napi-rs/nice-linux-ppc64-gnu': 1.1.1 + '@napi-rs/nice-linux-riscv64-gnu': 1.1.1 + '@napi-rs/nice-linux-s390x-gnu': 1.1.1 + '@napi-rs/nice-linux-x64-gnu': 1.1.1 + '@napi-rs/nice-linux-x64-musl': 1.1.1 + '@napi-rs/nice-openharmony-arm64': 1.1.1 + '@napi-rs/nice-win32-arm64-msvc': 1.1.1 + '@napi-rs/nice-win32-ia32-msvc': 1.1.1 + '@napi-rs/nice-win32-x64-msvc': 1.1.1 + optional: true + '@noble/ciphers@1.3.0': {} '@noble/hashes@1.8.0': {} @@ -9538,6 +9723,10 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.0.0 + piscina@5.1.4: + optionalDependencies: + '@napi-rs/nice': 1.1.1 + pixelmatch@4.0.2: dependencies: pngjs: 3.4.0 diff --git a/tests/unit/web/stores.test.ts b/tests/unit/web/stores.test.ts index 923ba363..327a45ca 100644 --- a/tests/unit/web/stores.test.ts +++ b/tests/unit/web/stores.test.ts @@ -416,25 +416,31 @@ describe("FileStore", () => { expect(useFileStore.getState().processedSize).toBe(500); }); - it("hasFiles returns true when entries exist", () => { - expect(useFileStore.getState().hasFiles).toBe(false); + it("entries.length reflects file count", () => { + expect(useFileStore.getState().entries.length).toBe(0); useFileStore.getState().setFiles([makeFile("a.png")]); - expect(useFileStore.getState().hasFiles).toBe(true); + expect(useFileStore.getState().entries.length).toBe(1); }); - it("allProcessed returns true when all entries are completed", () => { + it("all entries completed can be derived from entries", () => { useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); - expect(useFileStore.getState().allProcessed).toBe(false); + const allDone = () => + useFileStore.getState().entries.length > 0 && + useFileStore.getState().entries.every((e) => e.status === "completed"); + expect(allDone()).toBe(false); useFileStore.getState().updateEntry(0, { status: "completed" }); - expect(useFileStore.getState().allProcessed).toBe(false); + expect(allDone()).toBe(false); useFileStore.getState().updateEntry(1, { status: "completed" }); - expect(useFileStore.getState().allProcessed).toBe(true); + expect(allDone()).toBe(true); }); - it("allProcessed returns false when no entries", () => { - expect(useFileStore.getState().allProcessed).toBe(false); + it("empty entries means nothing is processed", () => { + const allDone = + useFileStore.getState().entries.length > 0 && + useFileStore.getState().entries.every((e) => e.status === "completed"); + expect(allDone).toBe(false); }); // -- setProcessedUrl (backward compat, updates current entry) -------------