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
+1
View File
@@ -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",
+51 -2
View File
@@ -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"));
+10 -3
View File
@@ -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);
},
};
}
+37 -1
View File
@@ -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<void> {
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<void> {
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<Buffer | null> {
try {
return await readFile(thumbPath(storedName));
} catch {
return null;
}
}
export async function saveThumbnail(storedName: string, buffer: Buffer): Promise<void> {
await ensureThumbDir();
await writeFile(thumbPath(storedName), buffer);
}
export async function deleteThumbnail(storedName: string): Promise<void> {
try {
await unlink(thumbPath(storedName));
} catch {
// Thumbnail may not exist
}
}
+61
View File
@@ -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<void> {
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<WorkerOutput> {
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,
};
}
+37
View File
@@ -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<void> {
if (pool) {
await pool.destroy();
pool = null;
}
}
+123
View File
@@ -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<string, JobProgress>();
/** SSE listeners waiting for updates, keyed by jobId. */
const listeners = new Map<string, Set<(data: JobProgress | SingleFileProgress) => 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<SingleFileProgress, "type">): 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<SingleFileProgress, "type">): void {
const event: SingleFileProgress = { ...progress, type: "single" };
persistSingleFileProgress(progress);
const subs = listeners.get(progress.jobId);
if (subs) {
for (const cb of subs) {
+48 -5
View File
@@ -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<T> {
@@ -41,6 +43,16 @@ export interface AnyToolRouteConfig {
*/
const toolRegistry = new Map<string, AnyToolRouteConfig>();
/** 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<T>(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();
+22 -2
View File
@@ -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<void> {
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<void> {
.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<void> {
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++;
}
+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
+304
View File
@@ -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<EraserCanvasRef | null>;
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 <ColorSettings toolId={toolId} onPreviewFilter={props.onPreviewFilter} />;
};
}
// ── 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 <CropSettings {...props.cropProps} />;
}
function EraseObjectSettingsWrapper(props: { eraserProps?: EraserProps }) {
if (!props.eraserProps) return null;
return <EraseObjectSettings {...props.eraserProps} />;
}
// ── The registry ───────────────────────────────────────────────────
export const toolRegistry = new Map<string, ToolRegistryEntry>([
// 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);
}
+210 -453
View File
@@ -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<EraserCanvasRef | null>;
hasStrokes: boolean;
brushSize: number;
onBrushSizeChange: (size: number) => void;
};
}) {
// Phase 2: Core tools
if (toolId === "resize") return <ResizeSettings />;
if (toolId === "crop" && cropProps) return <CropSettings {...cropProps} />;
if (toolId === "rotate") return <RotateSettings onPreviewTransform={onPreviewTransform} />;
if (toolId === "convert") return <ConvertSettings />;
if (toolId === "compress") return <CompressSettings />;
if (toolId === "strip-metadata") return <StripMetadataSettings />;
if (COLOR_TOOL_IDS.has(toolId))
return <ColorSettings toolId={toolId} onPreviewFilter={onPreviewFilter} />;
// Phase 3: Watermark & Overlay
if (toolId === "watermark-text") return <WatermarkTextSettings />;
if (toolId === "watermark-image") return <WatermarkImageSettings />;
if (toolId === "text-overlay") return <TextOverlaySettings />;
if (toolId === "compose") return <ComposeSettings />;
// Phase 3: Utilities
if (toolId === "info") return <InfoSettings />;
if (toolId === "compare") return <CompareSettings />;
if (toolId === "find-duplicates") return <FindDuplicatesSettings />;
if (toolId === "color-palette") return <ColorPaletteSettings />;
if (toolId === "qr-generate") return <QrGenerateSettings />;
if (toolId === "barcode-read") return <BarcodeReadSettings />;
// Phase 3: Layout & Composition
if (toolId === "collage") return <CollageSettings />;
if (toolId === "split") return <SplitSettings />;
if (toolId === "border") return <BorderSettings />;
// Phase 3: Format & Conversion
if (toolId === "svg-to-raster") return <SvgToRasterSettings />;
if (toolId === "vectorize") return <VectorizeSettings />;
if (toolId === "gif-tools") return <GifToolsSettings />;
// Phase 3: Optimization extras
if (toolId === "bulk-rename") return <BulkRenameSettings />;
if (toolId === "favicon") return <FaviconSettings />;
if (toolId === "image-to-pdf") return <ImageToPdfSettings />;
// Phase 3: Adjustments extra
if (toolId === "replace-color") return <ReplaceColorSettings />;
// Phase 4: AI Tools
if (toolId === "remove-background") return <RemoveBgSettings />;
if (toolId === "upscale") return <UpscaleSettings />;
if (toolId === "ocr") return <OcrSettings />;
if (toolId === "blur-faces") return <BlurFacesSettings />;
if (toolId === "erase-object" && eraserProps) return <EraseObjectSettings {...eraserProps} />;
if (toolId === "smart-crop") return <SmartCropSettings />;
return (
<p className="text-xs text-muted-foreground italic">Settings for this tool are coming soon.</p>
);
}
/** 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 (
<AppLayout>
<div className="flex items-center justify-center h-full text-muted-foreground">
@@ -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 (
<div className="text-center text-muted-foreground">
<p className="text-sm">Configure settings and generate.</p>
</div>
);
}
if (displayMode === "interactive-crop" && hasFile && !hasProcessed && originalBlobUrl) {
return (
<CropCanvas
imageSrc={originalBlobUrl}
crop={cropCrop}
aspect={cropAspect}
showGrid={cropShowGrid}
imgDimensions={cropImgDimensions}
onCropChange={setCropCrop}
onImageLoad={setCropImgDimensions}
/>
);
}
if (displayMode === "interactive-eraser" && hasFile && !hasProcessed && originalBlobUrl) {
return (
<EraserCanvas
ref={eraserRef}
imageSrc={originalBlobUrl}
brushSize={eraserBrushSize}
onStrokeChange={setEraserHasStrokes}
/>
);
}
if (hasProcessed && originalBlobUrl && displayMode === "side-by-side") {
return (
<SideBySideComparison
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
);
}
if (
hasProcessed &&
originalBlobUrl &&
(displayMode === "live-preview" || displayMode === "no-comparison")
) {
return (
<ImageViewer
src={processedUrl}
filename={processedFileName}
fileSize={processedSize ?? 0}
/>
);
}
if (hasProcessed && originalBlobUrl) {
return (
<BeforeAfterSlider
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
);
}
if (hasFile && originalBlobUrl) {
return (
<ImageViewer
src={originalBlobUrl}
filename={selectedFileName ?? files[0].name}
fileSize={selectedFileSize ?? files[0].size}
{...(isLivePreview && previewTransform
? {
cssRotate: previewTransform.rotate,
cssFlipH: previewTransform.flipH,
cssFlipV: previewTransform.flipV,
}
: {})}
{...(isLivePreview && previewFilter ? { cssFilter: previewFilter } : {})}
/>
);
}
return <Dropzone onFiles={handleFiles} accept="image/*" multiple currentFiles={files} />;
}
// Navigation arrows (shared between mobile/desktop)
function renderNavArrows() {
return (
<>
{hasMultiple && hasPrev && (
<button
type="button"
onClick={navigatePrev}
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Previous image"
>
<ChevronLeft className="h-4 w-4" />
</button>
)}
{hasMultiple && hasNext && (
<button
type="button"
onClick={navigateNext}
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Next image"
>
<ChevronRight className="h-4 w-4" />
</button>
)}
{hasMultiple && (
<div className="absolute top-3 right-3 z-10 bg-background/80 border border-border px-2 py-0.5 rounded-full text-xs text-muted-foreground tabular-nums">
{selectedIndex + 1} / {entries.length}
</div>
)}
</>
);
}
// Render the settings panel content (shared between mobile/desktop)
function renderSettingsContent() {
return (
<>
{!isNoDropzone && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">Files</h3>
<FileSelectionInfo
files={files}
selectedFileName={selectedFileName}
selectedFileSize={selectedFileSize}
onClear={reset}
onAddMore={handleAddMore}
/>
</div>
)}
<div className="border-t border-border" />
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">Settings</h3>
<Suspense fallback={<div className="text-xs text-muted-foreground">Loading...</div>}>
<ToolSettings {...settingsProps} />
</Suspense>
</div>
{hasProcessed && processedSize != null && (
<ReviewPanel
filename={processedFileName}
fileSize={processedSize}
fileType={processedFileType}
downloadUrl={processedUrl}
previewUrl={processedUrl}
onUndo={handleUndo}
currentToolId={tool?.id ?? ""}
/>
)}
</>
);
}
// Mobile layout: settings above dropzone (stacked)
if (isMobile) {
return (
@@ -366,69 +428,11 @@ export function ToolPage() {
{/* Collapsible settings */}
{mobileSettingsOpen && (
<div className="p-4 border-b border-border space-y-3 shrink-0 max-h-[40vh] overflow-y-auto">
{/* File info */}
{!isNoDropzone && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">Files</h3>
<FileSelectionInfo
files={files}
selectedFileName={selectedFileName}
selectedFileSize={selectedFileSize}
onClear={reset}
onAddMore={handleAddMore}
/>
</div>
)}
<div className="border-t border-border" />
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">Settings</h3>
<ToolSettingsPanel
toolId={tool.id}
onPreviewTransform={
LIVE_PREVIEW_TOOLS.has(tool.id) ? setPreviewTransform : undefined
}
onPreviewFilter={LIVE_PREVIEW_TOOLS.has(tool.id) ? setPreviewFilter : undefined}
cropProps={
INTERACTIVE_CROP_TOOLS.has(tool.id)
? {
cropState,
onCropChange: setCropCrop,
onAspectChange: setCropAspect,
onGridToggle: setCropShowGrid,
}
: undefined
}
eraserProps={
INTERACTIVE_ERASER_TOOLS.has(tool.id)
? {
eraserRef,
hasStrokes: eraserHasStrokes,
brushSize: eraserBrushSize,
onBrushSizeChange: setEraserBrushSize,
}
: undefined
}
/>
</div>
{/* Review panel (mobile) */}
{hasProcessed && processedSize != null && (
<ReviewPanel
filename={processedFileName}
fileSize={processedSize}
fileType={processedFileType}
downloadUrl={processedUrl}
previewUrl={processedUrl}
onUndo={handleUndo}
currentToolId={tool.id}
/>
)}
{renderSettingsContent()}
</div>
)}
{/* Main area: Dropzone / Image Viewer / Before-After */}
{/* Main area: image viewer */}
<section
aria-label="Image area"
className="flex-1 flex flex-col min-h-0"
@@ -436,103 +440,8 @@ export function ToolPage() {
tabIndex={hasMultiple ? 0 : undefined}
>
<div className="flex-1 relative flex items-center justify-center p-4 min-h-0">
{hasMultiple && hasPrev && (
<button
type="button"
onClick={navigatePrev}
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Previous image"
>
<ChevronLeft className="h-4 w-4" />
</button>
)}
{isNoDropzone ? (
<div className="text-center text-muted-foreground">
<p className="text-sm">Configure settings and generate.</p>
</div>
) : INTERACTIVE_CROP_TOOLS.has(tool.id) &&
hasFile &&
!hasProcessed &&
originalBlobUrl ? (
<CropCanvas
imageSrc={originalBlobUrl}
crop={cropCrop}
aspect={cropAspect}
showGrid={cropShowGrid}
imgDimensions={cropImgDimensions}
onCropChange={setCropCrop}
onImageLoad={setCropImgDimensions}
/>
) : INTERACTIVE_ERASER_TOOLS.has(tool.id) &&
hasFile &&
!hasProcessed &&
originalBlobUrl ? (
<EraserCanvas
ref={eraserRef}
imageSrc={originalBlobUrl}
brushSize={eraserBrushSize}
onStrokeChange={setEraserHasStrokes}
/>
) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? (
<SideBySideComparison
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? (
<ImageViewer
src={processedUrl}
filename={processedFileName}
fileSize={processedSize ?? 0}
/>
) : hasProcessed && originalBlobUrl && NO_COMPARISON_TOOLS.has(tool.id) ? (
<ImageViewer
src={processedUrl}
filename={processedFileName}
fileSize={processedSize ?? 0}
/>
) : hasProcessed && originalBlobUrl ? (
<BeforeAfterSlider
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
) : hasFile && originalBlobUrl ? (
<ImageViewer
src={originalBlobUrl}
filename={selectedFileName ?? files[0].name}
fileSize={selectedFileSize ?? files[0].size}
{...(LIVE_PREVIEW_TOOLS.has(tool.id) && previewTransform
? {
cssRotate: previewTransform.rotate,
cssFlipH: previewTransform.flipH,
cssFlipV: previewTransform.flipV,
}
: {})}
{...(LIVE_PREVIEW_TOOLS.has(tool.id) && previewFilter
? { cssFilter: previewFilter }
: {})}
/>
) : (
<Dropzone onFiles={handleFiles} accept="image/*" multiple currentFiles={files} />
)}
{hasMultiple && hasNext && (
<button
type="button"
onClick={navigateNext}
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Next image"
>
<ChevronRight className="h-4 w-4" />
</button>
)}
{hasMultiple && (
<div className="absolute top-3 right-3 z-10 bg-background/80 border border-border px-2 py-0.5 rounded-full text-xs text-muted-foreground tabular-nums">
{selectedIndex + 1} / {entries.length}
</div>
)}
{renderNavArrows()}
{renderImageArea()}
</div>
{hasMultiple && (
<ThumbnailStrip
@@ -560,64 +469,7 @@ export function ToolPage() {
<h2 className="font-semibold text-lg text-foreground">{tool.name}</h2>
</div>
{/* File info - hidden for tools that don't need files */}
{!isNoDropzone && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">Files</h3>
<FileSelectionInfo
files={files}
selectedFileName={selectedFileName}
selectedFileSize={selectedFileSize}
onClear={reset}
onAddMore={handleAddMore}
/>
</div>
)}
<div className="border-t border-border" />
{/* Tool-specific settings */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">Settings</h3>
<ToolSettingsPanel
toolId={tool.id}
onPreviewTransform={LIVE_PREVIEW_TOOLS.has(tool.id) ? setPreviewTransform : undefined}
onPreviewFilter={LIVE_PREVIEW_TOOLS.has(tool.id) ? setPreviewFilter : undefined}
cropProps={
INTERACTIVE_CROP_TOOLS.has(tool.id)
? {
cropState,
onCropChange: setCropCrop,
onAspectChange: setCropAspect,
onGridToggle: setCropShowGrid,
}
: undefined
}
eraserProps={
INTERACTIVE_ERASER_TOOLS.has(tool.id)
? {
eraserRef,
hasStrokes: eraserHasStrokes,
brushSize: eraserBrushSize,
onBrushSizeChange: setEraserBrushSize,
}
: undefined
}
/>
</div>
{/* Review panel (desktop - below settings) */}
{hasProcessed && processedSize != null && (
<ReviewPanel
filename={processedFileName}
fileSize={processedSize}
fileType={processedFileType}
downloadUrl={processedUrl}
previewUrl={processedUrl}
onUndo={handleUndo}
currentToolId={tool.id}
/>
)}
{renderSettingsContent()}
{/* Batch download */}
{entries.length > 1 && hasProcessed && batchZipBlob && (
@@ -635,7 +487,7 @@ export function ToolPage() {
)}
</div>
{/* Main area: Dropzone / Image Viewer / Before-After */}
{/* Main area: image viewer */}
<section
aria-label="Image area"
className="flex-1 flex flex-col min-h-0"
@@ -643,103 +495,8 @@ export function ToolPage() {
tabIndex={hasMultiple ? 0 : undefined}
>
<div className="flex-1 relative flex items-center justify-center p-6 min-h-0">
{hasMultiple && hasPrev && (
<button
type="button"
onClick={navigatePrev}
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Previous image"
>
<ChevronLeft className="h-4 w-4" />
</button>
)}
{isNoDropzone ? (
<div className="text-center text-muted-foreground">
<p className="text-sm">Configure settings and generate.</p>
</div>
) : INTERACTIVE_CROP_TOOLS.has(tool.id) &&
hasFile &&
!hasProcessed &&
originalBlobUrl ? (
<CropCanvas
imageSrc={originalBlobUrl}
crop={cropCrop}
aspect={cropAspect}
showGrid={cropShowGrid}
imgDimensions={cropImgDimensions}
onCropChange={setCropCrop}
onImageLoad={setCropImgDimensions}
/>
) : INTERACTIVE_ERASER_TOOLS.has(tool.id) &&
hasFile &&
!hasProcessed &&
originalBlobUrl ? (
<EraserCanvas
ref={eraserRef}
imageSrc={originalBlobUrl}
brushSize={eraserBrushSize}
onStrokeChange={setEraserHasStrokes}
/>
) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? (
<SideBySideComparison
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? (
<ImageViewer
src={processedUrl}
filename={processedFileName}
fileSize={processedSize ?? 0}
/>
) : hasProcessed && originalBlobUrl && NO_COMPARISON_TOOLS.has(tool.id) ? (
<ImageViewer
src={processedUrl}
filename={processedFileName}
fileSize={processedSize ?? 0}
/>
) : hasProcessed && originalBlobUrl ? (
<BeforeAfterSlider
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
) : hasFile && originalBlobUrl ? (
<ImageViewer
src={originalBlobUrl}
filename={selectedFileName ?? files[0].name}
fileSize={selectedFileSize ?? files[0].size}
{...(LIVE_PREVIEW_TOOLS.has(tool.id) && previewTransform
? {
cssRotate: previewTransform.rotate,
cssFlipH: previewTransform.flipH,
cssFlipV: previewTransform.flipV,
}
: {})}
{...(LIVE_PREVIEW_TOOLS.has(tool.id) && previewFilter
? { cssFilter: previewFilter }
: {})}
/>
) : (
<Dropzone onFiles={handleFiles} accept="image/*" multiple currentFiles={files} />
)}
{hasMultiple && hasNext && (
<button
type="button"
onClick={navigateNext}
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Next image"
>
<ChevronRight className="h-4 w-4" />
</button>
)}
{hasMultiple && (
<div className="absolute top-3 right-3 z-10 bg-background/80 border border-border px-2 py-0.5 rounded-full text-xs text-muted-foreground tabular-nums">
{selectedIndex + 1} / {entries.length}
</div>
)}
{renderNavArrows()}
{renderImageArea()}
</div>
{hasMultiple && (
<ThumbnailStrip
+53 -35
View File
@@ -35,6 +35,40 @@ function revokeEntries(entries: FileEntry[]): void {
}
}
// ---------------------------------------------------------------------------
// Derived state helpers
// ---------------------------------------------------------------------------
/**
* Derive fields from the selected entry. Only recomputes fields that are
* actually consumed by components (tool-page, home-page, use-tool-processor).
*/
function deriveSelected(entries: FileEntry[], selectedIndex: number) {
const entry = entries[selectedIndex];
return {
currentEntry: entry,
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,
};
}
/**
* Build the File[] array from entries, reusing the previous reference
* when the underlying File objects haven't changed.
*/
let prevFiles: File[] = [];
function deriveFiles(entries: FileEntry[]): File[] {
if (entries.length === prevFiles.length && entries.every((e, i) => 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<FileState>((set, get) => ({
entries: [],
selectedIndex: 0,
@@ -106,7 +118,8 @@ export const useFileStore = create<FileState>((set, get) => ({
error: null,
// Initial derived values (empty state)
...deriveCompat([], 0),
files: [],
...deriveSelected([], 0),
// -- Actions --------------------------------------------------------------
@@ -117,14 +130,15 @@ export const useFileStore = create<FileState>((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<FileState>((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<FileState>((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<FileState>((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<FileState>((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<FileState>((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<FileState>((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<FileState>((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<FileState>((set, get) => ({
batchZipFilename: null,
processing: false,
error: null,
...deriveCompat([], 0),
files: [],
...deriveSelected([], 0),
});
},
}));