mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user