Merge pull request #2 from siddharthksah/phase2-core-tools

Phase 2: Core Tools - Image Engine, 10 Tools, Batch Processing
This commit is contained in:
Siddharth Kumar Sah
2026-03-22 04:05:03 +08:00
committed by GitHub
56 changed files with 6345 additions and 35 deletions
+14 -9
View File
@@ -11,24 +11,29 @@
"clean": "rm -rf dist"
},
"dependencies": {
"@stirling-image/shared": "workspace:*",
"fastify": "^5.2.0",
"@fastify/static": "^8.1.0",
"@fastify/multipart": "^9.0.0",
"@fastify/cors": "^11.0.0",
"@fastify/multipart": "^9.0.0",
"@fastify/rate-limit": "^10.2.0",
"@fastify/static": "^8.1.0",
"@fastify/swagger": "^9.4.0",
"@fastify/swagger-ui": "^5.2.0",
"@stirling-image/image-engine": "workspace:*",
"@stirling-image/shared": "workspace:*",
"archiver": "^7.0.1",
"better-sqlite3": "^11.7.0",
"dotenv": "^16.4.0",
"zod": "^3.24.0",
"drizzle-orm": "^0.38.0",
"better-sqlite3": "^11.7.0"
"fastify": "^5.2.0",
"p-queue": "^9.1.0",
"sharp": "^0.33.0",
"zod": "^3.24.0"
},
"devDependencies": {
"typescript": "^5.7.0",
"tsx": "^4.19.0",
"@types/archiver": "^7.0.0",
"@types/better-sqlite3": "^7.6.0",
"@types/node": "^22.0.0",
"drizzle-kit": "^0.30.0",
"@types/better-sqlite3": "^7.6.0"
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}
+20
View File
@@ -7,8 +7,13 @@ import { env } from "./config.js";
import { APP_VERSION } from "@stirling-image/shared";
import { runMigrations } from "./db/migrate.js";
import { ensureDefaultAdmin, authRoutes, authMiddleware } from "./plugins/auth.js";
import { registerUpload } from "./plugins/upload.js";
import { registerStatic } from "./plugins/static.js";
import { startCleanupCron } from "./lib/cleanup.js";
import { fileRoutes } from "./routes/files.js";
import { registerToolRoutes } from "./routes/tools/index.js";
import { registerBatchRoutes } from "./routes/batch.js";
import { registerProgressRoutes } from "./routes/progress.js";
// Run before anything else
runMigrations();
@@ -45,12 +50,27 @@ await app.register(swaggerUi, {
routePrefix: "/api/docs",
});
// Multipart upload support
await registerUpload(app);
// Auth middleware (must be registered before routes it protects)
await authMiddleware(app);
// Auth routes
await authRoutes(app);
// File upload/download routes
await fileRoutes(app);
// Tool routes (generic factory-based)
await registerToolRoutes(app);
// Batch processing routes (must be after tool routes so the registry is populated)
await registerBatchRoutes(app);
// Progress SSE routes
await registerProgressRoutes(app);
// Health check
app.get("/api/v1/health", async () => ({
status: "healthy",
+118
View File
@@ -0,0 +1,118 @@
import sharp from "sharp";
import { env } from "../config.js";
/** Formats we accept as input. */
const SUPPORTED_INPUT_FORMATS = new Set([
"jpeg",
"png",
"webp",
"gif",
"tiff",
"bmp",
"avif",
]);
interface MagicEntry {
bytes: number[];
offset: number;
format: string;
}
const MAGIC_BYTES: MagicEntry[] = [
{ bytes: [0xff, 0xd8, 0xff], offset: 0, format: "jpeg" },
{ bytes: [0x89, 0x50, 0x4e, 0x47], offset: 0, format: "png" },
{ bytes: [0x52, 0x49, 0x46, 0x46], offset: 0, format: "webp" }, // RIFF; verified below
{ bytes: [0x47, 0x49, 0x46], offset: 0, format: "gif" },
{ bytes: [0x42, 0x4d], offset: 0, format: "bmp" },
{ bytes: [0x49, 0x49, 0x2a, 0x00], offset: 0, format: "tiff" },
{ bytes: [0x4d, 0x4d, 0x00, 0x2a], offset: 0, format: "tiff" },
];
export interface ValidationResult {
valid: true;
format: string;
width: number;
height: number;
}
export interface ValidationError {
valid: false;
reason: string;
}
/**
* Validate an uploaded image buffer.
*
* Checks:
* 1. Buffer is not empty
* 2. Magic bytes match a known image format
* 3. Format is in the supported input formats list
* 4. Image dimensions do not exceed MAX_MEGAPIXELS
*/
export async function validateImageBuffer(
buffer: Buffer,
): Promise<ValidationResult | ValidationError> {
// 1. Empty check
if (!buffer || buffer.length === 0) {
return { valid: false, reason: "File is empty" };
}
// 2. Magic byte detection
const detectedFormat = detectMagicBytes(buffer);
if (!detectedFormat) {
return { valid: false, reason: "Unrecognized image format" };
}
// 3. Supported format check
if (!SUPPORTED_INPUT_FORMATS.has(detectedFormat)) {
return {
valid: false,
reason: `Unsupported format: ${detectedFormat}`,
};
}
// 4. Dimensions check via sharp metadata
try {
const metadata = await sharp(buffer).metadata();
const width = metadata.width ?? 0;
const height = metadata.height ?? 0;
const megapixels = (width * height) / 1_000_000;
if (megapixels > env.MAX_MEGAPIXELS) {
return {
valid: false,
reason: `Image exceeds maximum size: ${megapixels.toFixed(1)}MP (limit: ${env.MAX_MEGAPIXELS}MP)`,
};
}
return { valid: true, format: detectedFormat, width, height };
} catch {
return { valid: false, reason: "Failed to read image metadata" };
}
}
function detectMagicBytes(buffer: Buffer): string | null {
for (const entry of MAGIC_BYTES) {
if (buffer.length < entry.offset + entry.bytes.length) continue;
let match = true;
for (let i = 0; i < entry.bytes.length; i++) {
if (buffer[entry.offset + i] !== entry.bytes[i]) {
match = false;
break;
}
}
if (match) {
// For RIFF, verify WEBP signature at bytes 8-11
if (entry.format === "webp") {
if (buffer.length < 12) continue;
const sig = buffer.slice(8, 12).toString("ascii");
if (sig !== "WEBP") continue;
}
return entry.format;
}
}
return null;
}
+29
View File
@@ -0,0 +1,29 @@
import { mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { env } from "../config.js";
/**
* Create a workspace directory structure for a processing job.
* Returns the workspace root path.
*/
export async function createWorkspace(jobId: string): Promise<string> {
const root = getWorkspacePath(jobId);
await mkdir(join(root, "input"), { recursive: true });
await mkdir(join(root, "output"), { recursive: true });
return root;
}
/**
* Get the workspace root path for a job.
*/
export function getWorkspacePath(jobId: string): string {
return join(env.WORKSPACE_PATH, jobId);
}
/**
* Remove the entire workspace directory for a job.
*/
export async function cleanupWorkspace(jobId: string): Promise<void> {
const root = getWorkspacePath(jobId);
await rm(root, { recursive: true, force: true });
}
+12
View File
@@ -0,0 +1,12 @@
import type { FastifyInstance } from "fastify";
import multipart from "@fastify/multipart";
import { env } from "../config.js";
export async function registerUpload(app: FastifyInstance): Promise<void> {
await app.register(multipart, {
limits: {
fileSize: env.MAX_UPLOAD_SIZE_MB * 1024 * 1024,
files: env.MAX_BATCH_SIZE,
},
});
}
+221
View File
@@ -0,0 +1,221 @@
/**
* Batch processing route.
*
* POST /api/v1/tools/:toolId/batch
*
* Accepts multipart with multiple files + settings JSON.
* Processes all files through the tool using p-queue for concurrency control.
* Returns a ZIP file containing all processed images.
*/
import { randomUUID } from "node:crypto";
import { basename } from "node:path";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import archiver from "archiver";
import PQueue from "p-queue";
import { getToolConfig } from "./tool-factory.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { env } from "../config.js";
import { updateJobProgress, type JobProgress } from "./progress.js";
/**
* Sanitize a filename to prevent path traversal attacks.
*/
function sanitizeFilename(raw: string): string {
let name = basename(raw);
name = name.replace(/\.\./g, "");
name = name.replace(/\0/g, "");
if (!name || name === "." || name === "..") {
name = "image";
}
return name;
}
interface ParsedFile {
buffer: Buffer;
filename: string;
}
export async function registerBatchRoutes(
app: FastifyInstance,
): Promise<void> {
app.post(
"/api/v1/tools/:toolId/batch",
async (
request: FastifyRequest<{ Params: { toolId: string } }>,
reply: FastifyReply,
) => {
const { toolId } = request.params;
// Look up the tool config from the registry
const toolConfig = getToolConfig(toolId);
if (!toolConfig) {
return reply.status(404).send({ error: `Tool "${toolId}" not found` });
}
// Parse multipart: collect all files and the settings field
const files: ParsedFile[] = [];
let settingsRaw: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
if (buffer.length > 0) {
files.push({
buffer,
filename: sanitizeFilename(part.filename ?? "image"),
});
}
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (files.length === 0) {
return reply.status(400).send({ error: "No image files provided" });
}
// Enforce batch size limit
if (files.length > env.MAX_BATCH_SIZE) {
return reply.status(400).send({
error: `Too many files. Maximum batch size is ${env.MAX_BATCH_SIZE}`,
});
}
// Parse and validate settings
let settings: unknown;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = toolConfig.settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({
error: "Invalid settings",
details: result.error.issues.map(
(i: { path: (string | number)[]; message: string }) => ({
path: i.path.join("."),
message: i.message,
}),
),
});
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
// Create a job ID for progress tracking
const jobId = randomUUID();
const progress: JobProgress = {
jobId,
status: "processing",
totalFiles: files.length,
completedFiles: 0,
failedFiles: 0,
errors: [],
};
updateJobProgress({ ...progress });
// Set up response headers for ZIP streaming
reply.raw.writeHead(200, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="batch-${toolId}-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
"X-Job-Id": jobId,
});
// Create ZIP archive that pipes directly to the response
const archive = archiver("zip", { zlib: { level: 5 } });
archive.pipe(reply.raw);
// Use p-queue for concurrency control
const queue = new PQueue({ concurrency: env.CONCURRENT_JOBS });
// Track unique filenames to avoid collisions in the ZIP
const usedNames = new Set<string>();
function getUniqueName(name: string): string {
if (!usedNames.has(name)) {
usedNames.add(name);
return name;
}
const dotIdx = name.lastIndexOf(".");
const base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
const ext = dotIdx > 0 ? name.slice(dotIdx) : "";
let counter = 1;
let candidate = `${base}_${counter}${ext}`;
while (usedNames.has(candidate)) {
counter++;
candidate = `${base}_${counter}${ext}`;
}
usedNames.add(candidate);
return candidate;
}
// Process all files through the queue
const tasks = files.map((file) =>
queue.add(async () => {
progress.currentFile = file.filename;
updateJobProgress({ ...progress });
// Validate the image
const validation = await validateImageBuffer(file.buffer);
if (!validation.valid) {
progress.failedFiles++;
progress.errors.push({
filename: file.filename,
error: `Invalid image: ${validation.reason}`,
});
progress.completedFiles++;
updateJobProgress({ ...progress });
return;
}
try {
const result = await toolConfig.process(
file.buffer,
settings,
file.filename,
);
const zipFilename = getUniqueName(result.filename);
archive.append(result.buffer, { name: zipFilename });
progress.completedFiles++;
updateJobProgress({ ...progress });
} catch (err) {
progress.failedFiles++;
progress.errors.push({
filename: file.filename,
error: err instanceof Error ? err.message : "Processing failed",
});
progress.completedFiles++;
updateJobProgress({ ...progress });
}
}),
);
// Wait for all tasks to complete
await Promise.all(tasks);
// Finalize progress
progress.status =
progress.failedFiles === progress.totalFiles ? "failed" : "completed";
progress.currentFile = undefined;
updateJobProgress({ ...progress });
// Finalize the ZIP archive (flushes remaining data and ends the stream)
await archive.finalize();
},
);
}
+162
View File
@@ -0,0 +1,162 @@
import { randomUUID } from "node:crypto";
import { writeFile, readFile, stat } from "node:fs/promises";
import { join, basename, extname } from "node:path";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { createWorkspace, getWorkspacePath } from "../lib/workspace.js";
import { validateImageBuffer } from "../lib/file-validation.js";
/**
* Sanitize a filename to prevent path traversal attacks.
* Strips directory separators and `..` sequences, keeps only the base name.
*/
function sanitizeFilename(raw: string): string {
// Take only the base name (no directories)
let name = basename(raw);
// Remove any remaining path traversal sequences
name = name.replace(/\.\./g, "");
// Remove null bytes
name = name.replace(/\0/g, "");
// If nothing is left, use a fallback
if (!name || name === "." || name === "..") {
name = "upload";
}
return name;
}
/**
* Guard against path traversal in URL params.
*/
function isPathTraversal(segment: string): boolean {
return (
segment.includes("..") ||
segment.includes("/") ||
segment.includes("\\") ||
segment.includes("\0")
);
}
export async function fileRoutes(app: FastifyInstance): Promise<void> {
// ── POST /api/v1/upload ────────────────────────────────────────
app.post(
"/api/v1/upload",
async (request: FastifyRequest, reply: FastifyReply) => {
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const inputDir = join(workspacePath, "input");
const uploadedFiles: Array<{
name: string;
size: number;
format: string;
}> = [];
const parts = request.parts();
for await (const part of parts) {
// Skip non-file fields
if (part.type !== "file") continue;
// Consume buffer from the stream
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
// Skip empty parts (e.g. empty file field)
if (buffer.length === 0) continue;
// Validate the image
const validation = await validateImageBuffer(buffer);
if (!validation.valid) {
return reply.status(400).send({
error: `Invalid file "${part.filename}": ${validation.reason}`,
});
}
// Sanitize filename
const safeName = sanitizeFilename(part.filename ?? "upload");
// Write to workspace input directory
const filePath = join(inputDir, safeName);
await writeFile(filePath, buffer);
uploadedFiles.push({
name: safeName,
size: buffer.length,
format: validation.format,
});
}
if (uploadedFiles.length === 0) {
return reply.status(400).send({ error: "No valid files uploaded" });
}
return reply.send({
jobId,
files: uploadedFiles,
});
},
);
// ── GET /api/v1/download/:jobId/:filename ──────────────────────
app.get(
"/api/v1/download/:jobId/:filename",
async (
request: FastifyRequest<{
Params: { jobId: string; filename: string };
}>,
reply: FastifyReply,
) => {
const { jobId, filename } = request.params;
// Guard against path traversal
if (isPathTraversal(jobId) || isPathTraversal(filename)) {
return reply.status(400).send({ error: "Invalid path" });
}
const workspacePath = getWorkspacePath(jobId);
// Try output directory first, then input
let filePath = join(workspacePath, "output", filename);
try {
await stat(filePath);
} catch {
filePath = join(workspacePath, "input", filename);
try {
await stat(filePath);
} catch {
return reply.status(404).send({ error: "File not found" });
}
}
const buffer = await readFile(filePath);
const ext = extname(filename).toLowerCase().replace(/^\./, "");
const contentType = getContentType(ext);
return reply
.header("Content-Type", contentType)
.header(
"Content-Disposition",
`attachment; filename="${encodeURIComponent(filename)}"`,
)
.send(buffer);
},
);
}
function getContentType(ext: string): string {
const map: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
gif: "image/gif",
bmp: "image/bmp",
tiff: "image/tiff",
tif: "image/tiff",
avif: "image/avif",
svg: "image/svg+xml",
};
return map[ext] ?? "application/octet-stream";
}
+119
View File
@@ -0,0 +1,119 @@
/**
* SSE endpoint for real-time job progress tracking.
*
* GET /api/v1/jobs/:jobId/progress
*
* Sends Server-Sent Events with progress data until the job finishes.
*/
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
export interface JobProgress {
jobId: string;
status: "processing" | "completed" | "failed";
totalFiles: number;
completedFiles: number;
failedFiles: number;
/** Names of files that failed, with error messages. */
errors: Array<{ filename: string; error: string }>;
/** Current file being processed (if any). */
currentFile?: string;
}
/** In-memory store of job progress, keyed by jobId. */
const jobProgressStore = new Map<string, JobProgress>();
/** SSE listeners waiting for updates, keyed by jobId. */
const listeners = new Map<string, Set<(data: JobProgress) => void>>();
/**
* Create or update progress for a job.
*/
export function updateJobProgress(progress: JobProgress): void {
jobProgressStore.set(progress.jobId, progress);
// Notify all SSE listeners
const subs = listeners.get(progress.jobId);
if (subs) {
for (const cb of subs) {
cb(progress);
}
// If the job is done, clean up listeners after a brief delay
if (progress.status === "completed" || progress.status === "failed") {
setTimeout(() => {
listeners.delete(progress.jobId);
jobProgressStore.delete(progress.jobId);
}, 5000);
}
}
}
/**
* Get current progress for a job.
*/
export function getJobProgress(jobId: string): JobProgress | undefined {
return jobProgressStore.get(jobId);
}
export async function registerProgressRoutes(
app: FastifyInstance,
): Promise<void> {
app.get(
"/api/v1/jobs/:jobId/progress",
async (
request: FastifyRequest<{ Params: { jobId: string } }>,
reply: FastifyReply,
) => {
const { jobId } = request.params;
// Send SSE headers via the raw Node response
reply.raw.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
// Helper to send an SSE message
const sendEvent = (data: JobProgress) => {
reply.raw.write(`data: ${JSON.stringify(data)}\n\n`);
};
// If the job already has progress, send it immediately
const existing = jobProgressStore.get(jobId);
if (existing) {
sendEvent(existing);
if (
existing.status === "completed" ||
existing.status === "failed"
) {
reply.raw.end();
return;
}
}
// Subscribe to updates
if (!listeners.has(jobId)) {
listeners.set(jobId, new Set());
}
const callback = (data: JobProgress) => {
sendEvent(data);
if (data.status === "completed" || data.status === "failed") {
reply.raw.end();
}
};
listeners.get(jobId)!.add(callback);
// Clean up on client disconnect
request.raw.on("close", () => {
const subs = listeners.get(jobId);
if (subs) {
subs.delete(callback);
if (subs.size === 0) {
listeners.delete(jobId);
}
}
});
},
);
}
+171
View File
@@ -0,0 +1,171 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join, extname, basename } from "node:path";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { z } from "zod";
import { createWorkspace } from "../lib/workspace.js";
import { validateImageBuffer } from "../lib/file-validation.js";
export interface ToolRouteConfig<T> {
/** Unique tool identifier, used as the URL path segment. */
toolId: string;
/** Zod schema that validates the settings JSON from the request. */
settingsSchema: z.ZodType<T, z.ZodTypeDef, unknown>;
/** The processing function: takes input buffer + validated settings, returns output. */
process: (
inputBuffer: Buffer,
settings: T,
filename: string,
) => Promise<{ buffer: Buffer; filename: string; contentType: string }>;
}
/**
* In-memory registry of all tool configs, keyed by toolId.
* Populated by createToolRoute() calls; used by batch processing.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const toolRegistry = new Map<string, ToolRouteConfig<any>>();
/**
* Retrieve a registered tool config by its ID.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function getToolConfig(toolId: string): ToolRouteConfig<any> | undefined {
return toolRegistry.get(toolId);
}
/**
* Sanitize a filename to prevent path traversal attacks.
*/
function sanitizeFilename(raw: string): string {
let name = basename(raw);
name = name.replace(/\.\./g, "");
name = name.replace(/\0/g, "");
if (!name || name === "." || name === "..") {
name = "image";
}
return name;
}
/**
* Factory that registers a POST /api/v1/tools/:toolId route.
*
* The route accepts multipart with:
* - A file part (the image to process)
* - A "settings" field containing a JSON string
*
* The factory handles:
* - Multipart parsing
* - File validation
* - Settings validation via Zod
* - Workspace management
* - Error handling
* - Response formatting
*/
export function createToolRoute<T>(
app: FastifyInstance,
config: ToolRouteConfig<T>,
): void {
// Register in the tool registry for batch processing
toolRegistry.set(config.toolId, config);
app.post(
`/api/v1/tools/${config.toolId}`,
async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
// Parse multipart parts
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
// Consume the file stream into a buffer
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = sanitizeFilename(part.filename ?? "image");
} else {
// Field part
if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
// Require a file
if (!fileBuffer || fileBuffer.length === 0) {
return reply
.status(400)
.send({ error: "No image file provided" });
}
// Validate the uploaded image
const validation = await validateImageBuffer(fileBuffer);
if (!validation.valid) {
return reply
.status(400)
.send({ error: `Invalid image: ${validation.reason}` });
}
// Parse and validate settings
let settings: T;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = config.settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({
error: "Invalid settings",
details: result.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
});
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
// Process the image
try {
const result = await config.process(fileBuffer, settings, filename);
// Create workspace and save output
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", result.filename);
await writeFile(outputPath, result.buffer);
// Also save the original input for reference/download
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(result.filename)}`,
originalSize: fileBuffer.length,
processedSize: result.buffer.length,
});
} catch (err) {
// Catch Sharp / processing errors and return a clean API error
const message =
err instanceof Error ? err.message : "Image processing failed";
return reply.status(422).send({
error: "Processing failed",
details: message,
});
}
},
);
}
@@ -0,0 +1,99 @@
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import {
brightness as adjustBrightness,
contrast as adjustContrast,
saturation as adjustSaturation,
colorChannels,
grayscale,
sepia,
invert,
} from "@stirling-image/image-engine";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
const settingsSchema = z.object({
brightness: z.number().min(-100).max(100).default(0),
contrast: z.number().min(-100).max(100).default(0),
saturation: z.number().min(-100).max(100).default(0),
red: z.number().min(0).max(200).default(100),
green: z.number().min(0).max(200).default(100),
blue: z.number().min(0).max(200).default(100),
effect: z
.enum(["none", "grayscale", "sepia", "invert"])
.default("none"),
});
/**
* Combined color adjustment route that handles brightness, contrast,
* saturation, color channels, and color effects in a single request.
*
* Serves tool IDs: brightness-contrast, saturation, color-channels, color-effects
*/
export function registerColorAdjustments(app: FastifyInstance) {
// Register the same handler under all four color-related tool IDs
const toolIds = [
"brightness-contrast",
"saturation",
"color-channels",
"color-effects",
];
for (const toolId of toolIds) {
createToolRoute(app, {
toolId,
settingsSchema,
process: async (inputBuffer, settings, filename) => {
let image = sharp(inputBuffer);
// Apply brightness
if (settings.brightness !== 0) {
image = await adjustBrightness(image, {
value: settings.brightness,
});
}
// Apply contrast
if (settings.contrast !== 0) {
image = await adjustContrast(image, { value: settings.contrast });
}
// Apply saturation
if (settings.saturation !== 0) {
image = await adjustSaturation(image, {
value: settings.saturation,
});
}
// Apply color channels (only if not default 100/100/100)
if (
settings.red !== 100 ||
settings.green !== 100 ||
settings.blue !== 100
) {
image = await colorChannels(image, {
red: settings.red,
green: settings.green,
blue: settings.blue,
});
}
// Apply effect
switch (settings.effect) {
case "grayscale":
image = await grayscale(image);
break;
case "sepia":
image = await sepia(image);
break;
case "invert":
image = await invert(image);
break;
}
const buffer = await image.toBuffer();
return { buffer, filename, contentType: "image/png" };
},
});
}
}
+37
View File
@@ -0,0 +1,37 @@
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import { compress } from "@stirling-image/image-engine";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
const settingsSchema = z.object({
mode: z.enum(["quality", "targetSize"]).default("quality"),
quality: z.number().min(1).max(100).optional(),
targetSizeKb: z.number().positive().optional(),
});
export function registerCompress(app: FastifyInstance) {
createToolRoute(app, {
toolId: "compress",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
const image = sharp(inputBuffer);
const compressOptions: {
quality?: number;
targetSizeBytes?: number;
} = {};
if (settings.mode === "targetSize" && settings.targetSizeKb) {
// Convert KB to bytes for the engine
compressOptions.targetSizeBytes = settings.targetSizeKb * 1024;
} else {
compressOptions.quality = settings.quality ?? 80;
}
const result = await compress(image, compressOptions);
const buffer = await result.toBuffer();
return { buffer, filename, contentType: "image/jpeg" };
},
});
}
+42
View File
@@ -0,0 +1,42 @@
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import { convert } from "@stirling-image/image-engine";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
import { extname } from "node:path";
const FORMAT_CONTENT_TYPES: Record<string, string> = {
jpg: "image/jpeg",
png: "image/png",
webp: "image/webp",
avif: "image/avif",
tiff: "image/tiff",
gif: "image/gif",
};
const settingsSchema = z.object({
format: z.enum(["jpg", "png", "webp", "avif", "tiff", "gif"]),
quality: z.number().min(1).max(100).optional(),
});
export function registerConvert(app: FastifyInstance) {
createToolRoute(app, {
toolId: "convert",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
const image = sharp(inputBuffer);
const result = await convert(image, settings);
const buffer = await result.toBuffer();
// Change filename extension to match the output format
const ext = extname(filename);
const baseName = ext ? filename.slice(0, -ext.length) : filename;
const outputFilename = `${baseName}.${settings.format}`;
const contentType =
FORMAT_CONTENT_TYPES[settings.format] || "application/octet-stream";
return { buffer, filename: outputFilename, contentType };
},
});
}
+25
View File
@@ -0,0 +1,25 @@
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import { crop } from "@stirling-image/image-engine";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
const settingsSchema = z.object({
left: z.number().int().min(0),
top: z.number().int().min(0),
width: z.number().int().positive(),
height: z.number().int().positive(),
});
export function registerCrop(app: FastifyInstance) {
createToolRoute(app, {
toolId: "crop",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
const image = sharp(inputBuffer);
const result = await crop(image, settings);
const buffer = await result.toBuffer();
return { buffer, filename, contentType: "image/png" };
},
});
}
+24
View File
@@ -0,0 +1,24 @@
import type { FastifyInstance } from "fastify";
import { registerResize } from "./resize.js";
import { registerCrop } from "./crop.js";
import { registerRotate } from "./rotate.js";
import { registerConvert } from "./convert.js";
import { registerCompress } from "./compress.js";
import { registerStripMetadata } from "./strip-metadata.js";
import { registerColorAdjustments } from "./color-adjustments.js";
/**
* Registry that imports and registers all tool routes.
* Each tool uses the createToolRoute factory from tool-factory.ts.
*/
export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
registerResize(app);
registerCrop(app);
registerRotate(app);
registerConvert(app);
registerCompress(app);
registerStripMetadata(app);
registerColorAdjustments(app);
app.log.info("Tool routes registered (7 tools, 10 endpoints)");
}
+28
View File
@@ -0,0 +1,28 @@
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import { resize } from "@stirling-image/image-engine";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
const settingsSchema = z.object({
width: z.number().positive().optional(),
height: z.number().positive().optional(),
fit: z
.enum(["contain", "cover", "fill", "inside", "outside"])
.default("contain"),
withoutEnlargement: z.boolean().default(false),
percentage: z.number().positive().optional(),
});
export function registerResize(app: FastifyInstance) {
createToolRoute(app, {
toolId: "resize",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
const image = sharp(inputBuffer);
const result = await resize(image, settings);
const buffer = await result.toBuffer();
return { buffer, filename, contentType: "image/png" };
},
});
}
+37
View File
@@ -0,0 +1,37 @@
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import { rotate, flip } from "@stirling-image/image-engine";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
const settingsSchema = z.object({
angle: z.number().default(0),
horizontal: z.boolean().default(false),
vertical: z.boolean().default(false),
});
export function registerRotate(app: FastifyInstance) {
createToolRoute(app, {
toolId: "rotate",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
let image = sharp(inputBuffer);
// Apply rotation first
if (settings.angle !== 0) {
image = await rotate(image, { angle: settings.angle });
}
// Then apply flip/flop
if (settings.horizontal || settings.vertical) {
image = await flip(image, {
horizontal: settings.horizontal,
vertical: settings.vertical,
});
}
const buffer = await image.toBuffer();
return { buffer, filename, contentType: "image/png" };
},
});
}
@@ -0,0 +1,26 @@
import { z } from "zod";
import { createToolRoute } from "../tool-factory.js";
import { stripMetadata } from "@stirling-image/image-engine";
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
const settingsSchema = z.object({
stripExif: z.boolean().default(false),
stripGps: z.boolean().default(false),
stripIcc: z.boolean().default(false),
stripXmp: z.boolean().default(false),
stripAll: z.boolean().default(true),
});
export function registerStripMetadata(app: FastifyInstance) {
createToolRoute(app, {
toolId: "strip-metadata",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
const image = sharp(inputBuffer);
const result = await stripMetadata(image, settings);
const buffer = await result.toBuffer();
return { buffer, filename, contentType: "image/png" };
},
});
}
@@ -0,0 +1,178 @@
import { useRef, useState, useCallback, useEffect, type PointerEvent } from "react";
interface BeforeAfterSliderProps {
/** URL or data URL of original image. */
beforeSrc: string;
/** URL or data URL of processed image. */
afterSrc: string;
/** Original file size in bytes. */
beforeSize?: number;
/** Processed file size in bytes. */
afterSize?: number;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
/**
* Before/After image comparison slider.
*
* Shows two images overlapping with a draggable vertical divider.
* The "before" image is on the left, "after" on the right.
* Supports mouse and touch interaction via pointer events.
*/
export function BeforeAfterSlider({
beforeSrc,
afterSrc,
beforeSize,
afterSize,
}: BeforeAfterSliderProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState(50); // percentage 0-100
const [isDragging, setIsDragging] = useState(false);
const updatePosition = useCallback(
(clientX: number) => {
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const x = clientX - rect.left;
const pct = Math.max(0, Math.min(100, (x / rect.width) * 100));
setPosition(pct);
},
[],
);
const handlePointerDown = useCallback(
(e: PointerEvent) => {
e.preventDefault();
setIsDragging(true);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
updatePosition(e.clientX);
},
[updatePosition],
);
const handlePointerMove = useCallback(
(e: PointerEvent) => {
if (!isDragging) return;
updatePosition(e.clientX);
},
[isDragging, updatePosition],
);
const handlePointerUp = useCallback(() => {
setIsDragging(false);
}, []);
// Prevent default drag behavior on images
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const preventDrag = (e: Event) => e.preventDefault();
container.addEventListener("dragstart", preventDrag);
return () => container.removeEventListener("dragstart", preventDrag);
}, []);
const savingsPercent =
beforeSize && afterSize && beforeSize > 0
? ((1 - afterSize / beforeSize) * 100).toFixed(1)
: null;
return (
<div className="flex flex-col items-center gap-3 w-full max-w-2xl mx-auto">
{/* Slider container */}
<div
ref={containerRef}
className="relative w-full overflow-hidden rounded-lg border border-border select-none touch-none"
style={{ cursor: isDragging ? "ew-resize" : "default" }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{/* Before image (full width, bottom layer) */}
<img
src={beforeSrc}
alt="Original"
className="block w-full h-auto"
draggable={false}
/>
{/* After image (clipped, top layer) */}
<img
src={afterSrc}
alt="Processed"
className="absolute inset-0 w-full h-full object-contain"
draggable={false}
style={{
clipPath: `inset(0 0 0 ${position}%)`,
}}
/>
{/* Divider line */}
<div
className="absolute top-0 bottom-0 w-0.5 bg-white/80 pointer-events-none"
style={{ left: `${position}%`, transform: "translateX(-50%)" }}
>
{/* Handle grip */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-white border-2 border-primary shadow-lg flex items-center justify-center pointer-events-none">
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
className="text-primary"
>
<path
d="M4 3L1 7L4 11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10 3L13 7L10 11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</div>
{/* Labels */}
<div className="absolute top-2 left-2 px-2 py-0.5 rounded bg-black/50 text-white text-xs font-medium pointer-events-none">
Original
</div>
<div className="absolute top-2 right-2 px-2 py-0.5 rounded bg-black/50 text-white text-xs font-medium pointer-events-none">
Processed
</div>
</div>
{/* Size comparison badges */}
{beforeSize != null && afterSize != null && (
<div className="flex items-center gap-4 text-xs">
<span className="px-2 py-1 rounded bg-muted text-muted-foreground">
Original: {formatSize(beforeSize)}
</span>
<span className="px-2 py-1 rounded bg-primary/10 text-primary font-medium">
Processed: {formatSize(afterSize)}
{savingsPercent !== null && Number(savingsPercent) > 0 && (
<span className="ml-1">({savingsPercent}% smaller)</span>
)}
{savingsPercent !== null && Number(savingsPercent) < 0 && (
<span className="ml-1">
({Math.abs(Number(savingsPercent))}% larger)
</span>
)}
</span>
</div>
)}
</div>
);
}
+27 -2
View File
@@ -1,14 +1,16 @@
import { useCallback, useState, type DragEvent } from "react";
import { Upload } from "lucide-react";
import { Upload, FileImage } from "lucide-react";
import { cn } from "@/lib/utils";
interface DropzoneProps {
onFiles?: (files: File[]) => void;
accept?: string;
multiple?: boolean;
/** Files that have already been dropped (for showing count & list). */
currentFiles?: File[];
}
export function Dropzone({ onFiles, accept, multiple = true }: DropzoneProps) {
export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }: DropzoneProps) {
const [isDragging, setIsDragging] = useState(false);
const handleDrag = useCallback((e: DragEvent) => {
@@ -41,6 +43,8 @@ export function Dropzone({ onFiles, accept, multiple = true }: DropzoneProps) {
input.click();
};
const hasMultipleFiles = currentFiles.length > 1;
return (
<div
onDragEnter={handleDrag}
@@ -66,6 +70,27 @@ export function Dropzone({ onFiles, accept, multiple = true }: DropzoneProps) {
<p className="text-sm text-muted-foreground">
Drop files here or click the upload button
</p>
{/* Show file count badge and list when multiple files are dropped */}
{hasMultipleFiles && (
<div className="flex flex-col items-center gap-2 mt-2">
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium">
<FileImage className="h-3.5 w-3.5" />
{currentFiles.length} files selected
</span>
<div className="max-h-32 overflow-y-auto w-full max-w-xs">
{currentFiles.map((f, i) => (
<div
key={i}
className="flex items-center justify-between text-xs text-muted-foreground px-2 py-0.5"
>
<span className="truncate">{f.name}</span>
<span className="shrink-0 ml-2">{(f.size / 1024).toFixed(0)} KB</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
@@ -0,0 +1,250 @@
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { Download, Loader2 } from "lucide-react";
type Tab = "basic" | "channels" | "effects";
type Effect = "none" | "grayscale" | "sepia" | "invert";
interface ColorSettingsProps {
/** The specific tool ID to use for processing */
toolId: string;
}
export function ColorSettings({ toolId }: ColorSettingsProps) {
const { files } = useFileStore();
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } =
useToolProcessor(toolId);
const [tab, setTab] = useState<Tab>(() => {
if (toolId === "color-channels") return "channels";
if (toolId === "color-effects") return "effects";
return "basic";
});
// Basic adjustments
const [brightness, setBrightness] = useState(0);
const [contrast, setContrast] = useState(0);
const [saturation, setSaturation] = useState(0);
// Color channels
const [red, setRed] = useState(100);
const [green, setGreen] = useState(100);
const [blue, setBlue] = useState(100);
// Effects
const [effect, setEffect] = useState<Effect>("none");
const handleProcess = () => {
processFiles(files, {
brightness,
contrast,
saturation,
red,
green,
blue,
effect,
});
};
const hasFile = files.length > 0;
const hasChanges =
brightness !== 0 ||
contrast !== 0 ||
saturation !== 0 ||
red !== 100 ||
green !== 100 ||
blue !== 100 ||
effect !== "none";
const tabs: { id: Tab; label: string }[] = [
{ id: "basic", label: "Basic" },
{ id: "channels", label: "Channels" },
{ id: "effects", label: "Effects" },
];
return (
<div className="space-y-4">
{/* Tabs */}
<div className="flex gap-1">
{tabs.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`flex-1 text-xs py-1.5 rounded ${
tab === t.id
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
{t.label}
</button>
))}
</div>
{/* Basic Adjustments */}
{tab === "basic" && (
<div className="space-y-3">
<SliderControl
label="Brightness"
value={brightness}
onChange={setBrightness}
min={-100}
max={100}
/>
<SliderControl
label="Contrast"
value={contrast}
onChange={setContrast}
min={-100}
max={100}
/>
<SliderControl
label="Saturation"
value={saturation}
onChange={setSaturation}
min={-100}
max={100}
/>
</div>
)}
{/* Color Channels */}
{tab === "channels" && (
<div className="space-y-3">
<SliderControl
label="Red"
value={red}
onChange={setRed}
min={0}
max={200}
color="text-red-500"
/>
<SliderControl
label="Green"
value={green}
onChange={setGreen}
min={0}
max={200}
color="text-green-500"
/>
<SliderControl
label="Blue"
value={blue}
onChange={setBlue}
min={0}
max={200}
color="text-blue-500"
/>
</div>
)}
{/* Effects */}
{tab === "effects" && (
<div className="space-y-2">
<label className="text-xs text-muted-foreground">Color Effect</label>
<div className="grid grid-cols-2 gap-1">
{(["none", "grayscale", "sepia", "invert"] as const).map((e) => (
<button
key={e}
onClick={() => setEffect(e)}
className={`text-xs py-2 rounded capitalize transition-colors ${
effect === e
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-primary/10"
}`}
>
{e}
</button>
))}
</div>
</div>
)}
{/* Reset button */}
{hasChanges && (
<button
onClick={() => {
setBrightness(0);
setContrast(0);
setSaturation(0);
setRed(100);
setGreen(100);
setBlue(100);
setEffect("none");
}}
className="w-full text-xs py-1.5 rounded border border-border text-muted-foreground hover:text-foreground"
>
Reset All
</button>
)}
{/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>}
{/* Size info */}
{originalSize != null && processedSize != null && (
<div className="text-xs text-muted-foreground space-y-0.5">
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
</div>
)}
{/* Process */}
<button
onClick={handleProcess}
disabled={!hasFile || !hasChanges || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Processing..." : "Apply Adjustments"}
</button>
{/* Download */}
{downloadUrl && (
<a
href={downloadUrl}
download
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
</a>
)}
</div>
);
}
/** Reusable slider control */
function SliderControl({
label,
value,
onChange,
min,
max,
color,
}: {
label: string;
value: number;
onChange: (v: number) => void;
min: number;
max: number;
color?: string;
}) {
return (
<div>
<div className="flex justify-between items-center">
<label className={`text-xs ${color || "text-muted-foreground"}`}>{label}</label>
<span className="text-xs font-mono text-foreground">{value}</span>
</div>
<input
type="range"
min={min}
max={max}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full mt-0.5"
/>
</div>
);
}
@@ -0,0 +1,126 @@
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { Download, Loader2 } from "lucide-react";
type CompressMode = "quality" | "targetSize";
export function CompressSettings() {
const { files } = useFileStore();
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } =
useToolProcessor("compress");
const [mode, setMode] = useState<CompressMode>("quality");
const [quality, setQuality] = useState(75);
const [targetSizeKb, setTargetSizeKb] = useState("");
const handleProcess = () => {
const settings: Record<string, unknown> = { mode };
if (mode === "quality") {
settings.quality = quality;
} else {
settings.targetSizeKb = Number(targetSizeKb);
}
processFiles(files, settings);
};
const hasFile = files.length > 0;
const canProcess =
mode === "quality" || (mode === "targetSize" && Number(targetSizeKb) > 0);
return (
<div className="space-y-4">
{/* Mode toggle */}
<div>
<label className="text-sm font-medium text-muted-foreground">Compression Mode</label>
<div className="flex gap-1 mt-1">
<button
onClick={() => setMode("quality")}
className={`flex-1 text-xs py-1.5 rounded ${mode === "quality" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Quality
</button>
<button
onClick={() => setMode("targetSize")}
className={`flex-1 text-xs py-1.5 rounded ${mode === "targetSize" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Target Size
</button>
</div>
</div>
{mode === "quality" ? (
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Quality</label>
<span className="text-xs font-mono text-foreground">{quality}</span>
</div>
<input
type="range"
min={1}
max={100}
value={quality}
onChange={(e) => setQuality(Number(e.target.value))}
className="w-full mt-1"
/>
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
<span>Smallest file</span>
<span>Best quality</span>
</div>
</div>
) : (
<div>
<label className="text-xs text-muted-foreground">Target Size (KB)</label>
<input
type="number"
value={targetSizeKb}
onChange={(e) => setTargetSizeKb(e.target.value)}
min={1}
placeholder="e.g. 200"
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
)}
{/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>}
{/* Size info */}
{originalSize != null && processedSize != null && (
<div className="text-xs text-muted-foreground space-y-0.5">
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
<p className="font-medium text-foreground">
Saved:{" "}
{originalSize > 0
? ((1 - processedSize / originalSize) * 100).toFixed(1)
: "0"}
%
</p>
</div>
)}
{/* Process */}
<button
onClick={handleProcess}
disabled={!hasFile || !canProcess || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Compressing..." : "Compress"}
</button>
{/* Download */}
{downloadUrl && (
<a
href={downloadUrl}
download
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
</a>
)}
</div>
);
}
@@ -0,0 +1,122 @@
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { Download, Loader2 } from "lucide-react";
const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif"] as const;
const LOSSY_FORMATS = new Set(["jpg", "webp", "avif"]);
export function ConvertSettings() {
const { files } = useFileStore();
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } =
useToolProcessor("convert");
const [format, setFormat] = useState<string>("png");
const [quality, setQuality] = useState(85);
// Detect source format from filename
const sourceFile = files[0];
const sourceExt = sourceFile
? sourceFile.name.split(".").pop()?.toLowerCase() || "unknown"
: "none";
const isLossy = LOSSY_FORMATS.has(format);
const handleProcess = () => {
const settings: Record<string, unknown> = { format };
if (isLossy) {
settings.quality = quality;
}
processFiles(files, settings);
};
const hasFile = files.length > 0;
return (
<div className="space-y-4">
{/* Source format */}
{hasFile && (
<div>
<label className="text-xs text-muted-foreground">Source Format</label>
<div className="mt-0.5 px-2 py-1.5 rounded bg-muted text-sm text-foreground uppercase font-mono">
{sourceExt}
</div>
</div>
)}
{/* Target format */}
<div>
<label className="text-xs text-muted-foreground">Target Format</label>
<select
value={format}
onChange={(e) => setFormat(e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
{OUTPUT_FORMATS.map((f) => (
<option key={f} value={f}>
{f.toUpperCase()}
</option>
))}
</select>
</div>
{/* Quality slider (lossy only) */}
{isLossy && (
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Quality</label>
<span className="text-xs font-mono text-foreground">{quality}</span>
</div>
<input
type="range"
min={1}
max={100}
value={quality}
onChange={(e) => setQuality(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
)}
{/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>}
{/* Size info */}
{originalSize != null && processedSize != null && (
<div className="text-xs text-muted-foreground space-y-0.5">
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
<p>
Savings:{" "}
{originalSize > 0
? ((1 - processedSize / originalSize) * 100).toFixed(1)
: "0"}
%
</p>
</div>
)}
{/* Process */}
<button
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Converting..." : `Convert to ${format.toUpperCase()}`}
</button>
{/* Download */}
{downloadUrl && (
<a
href={downloadUrl}
download
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
</a>
)}
</div>
);
}
@@ -0,0 +1,144 @@
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { Download, Loader2 } from "lucide-react";
const ASPECT_PRESETS = [
{ label: "1:1", w: 1, h: 1 },
{ label: "4:3", w: 4, h: 3 },
{ label: "16:9", w: 16, h: 9 },
{ label: "2:3", w: 2, h: 3 },
{ label: "4:5", w: 4, h: 5 },
];
export function CropSettings() {
const { files } = useFileStore();
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } =
useToolProcessor("crop");
const [left, setLeft] = useState("0");
const [top, setTop] = useState("0");
const [width, setWidth] = useState("");
const [height, setHeight] = useState("");
const applyAspect = (w: number, h: number) => {
// If width is set, calculate height from aspect ratio
const currentW = Number(width);
if (currentW > 0) {
setHeight(String(Math.round((currentW * h) / w)));
}
};
const handleProcess = () => {
processFiles(files, {
left: Number(left),
top: Number(top),
width: Number(width),
height: Number(height),
});
};
const hasFile = files.length > 0;
const hasSize = Number(width) > 0 && Number(height) > 0;
return (
<div className="space-y-4">
{/* Position */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-muted-foreground">Left (px)</label>
<input
type="number"
value={left}
onChange={(e) => setLeft(e.target.value)}
min={0}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Top (px)</label>
<input
type="number"
value={top}
onChange={(e) => setTop(e.target.value)}
min={0}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
</div>
{/* Size */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-muted-foreground">Width (px)</label>
<input
type="number"
value={width}
onChange={(e) => setWidth(e.target.value)}
min={1}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Height (px)</label>
<input
type="number"
value={height}
onChange={(e) => setHeight(e.target.value)}
min={1}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
</div>
{/* Aspect ratio presets */}
<div>
<label className="text-xs text-muted-foreground">Aspect Ratio</label>
<div className="flex gap-1 mt-1">
{ASPECT_PRESETS.map(({ label, w, h }) => (
<button
key={label}
onClick={() => applyAspect(w, h)}
className="flex-1 text-xs py-1.5 rounded bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors"
>
{label}
</button>
))}
</div>
</div>
{/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>}
{/* Size info */}
{originalSize != null && processedSize != null && (
<div className="text-xs text-muted-foreground space-y-0.5">
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
</div>
)}
{/* Process */}
<button
onClick={handleProcess}
disabled={!hasFile || !hasSize || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Processing..." : "Crop"}
</button>
{/* Download */}
{downloadUrl && (
<a
href={downloadUrl}
download
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
</a>
)}
</div>
);
}
@@ -0,0 +1,204 @@
import { useState } from "react";
import { SOCIAL_MEDIA_PRESETS } from "@stirling-image/shared";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { Download, Link, Unlink, Loader2 } from "lucide-react";
type FitMode = "contain" | "cover" | "fill" | "inside" | "outside";
export function ResizeSettings() {
const { files } = useFileStore();
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } =
useToolProcessor("resize");
const [mode, setMode] = useState<"pixels" | "percentage">("pixels");
const [width, setWidth] = useState<string>("");
const [height, setHeight] = useState<string>("");
const [percentage, setPercentage] = useState<string>("100");
const [fit, setFit] = useState<FitMode>("contain");
const [lockAspect, setLockAspect] = useState(true);
const [withoutEnlargement, setWithoutEnlargement] = useState(false);
const handlePreset = (w: number, h: number) => {
setMode("pixels");
setWidth(String(w));
setHeight(String(h));
};
const handleProcess = () => {
const settings: Record<string, unknown> = { fit, withoutEnlargement };
if (mode === "percentage") {
settings.percentage = Number(percentage);
} else {
if (width) settings.width = Number(width);
if (height) settings.height = Number(height);
}
processFiles(files, settings);
};
const hasFile = files.length > 0;
// Group presets by platform
const platforms = [...new Set(SOCIAL_MEDIA_PRESETS.map((p) => p.platform))];
return (
<div className="space-y-4">
{/* Mode toggle */}
<div>
<label className="text-sm font-medium text-muted-foreground">Resize Mode</label>
<div className="flex gap-1 mt-1">
<button
onClick={() => setMode("pixels")}
className={`flex-1 text-xs py-1.5 rounded ${mode === "pixels" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Pixels
</button>
<button
onClick={() => setMode("percentage")}
className={`flex-1 text-xs py-1.5 rounded ${mode === "percentage" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Percentage
</button>
</div>
</div>
{mode === "pixels" ? (
<>
{/* Width / Height */}
<div className="space-y-2">
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Width (px)</label>
<input
type="number"
value={width}
onChange={(e) => setWidth(e.target.value)}
placeholder="Auto"
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<button
onClick={() => setLockAspect(!lockAspect)}
className="p-1.5 rounded border border-border text-muted-foreground hover:text-foreground"
title={lockAspect ? "Unlock aspect ratio" : "Lock aspect ratio"}
>
{lockAspect ? <Link className="h-4 w-4" /> : <Unlink className="h-4 w-4" />}
</button>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Height (px)</label>
<input
type="number"
value={height}
onChange={(e) => setHeight(e.target.value)}
placeholder="Auto"
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
</div>
</div>
{/* Fit mode */}
<div>
<label className="text-xs text-muted-foreground">Fit Mode</label>
<select
value={fit}
onChange={(e) => setFit(e.target.value as FitMode)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="contain">Contain</option>
<option value="cover">Cover</option>
<option value="fill">Fill (stretch)</option>
<option value="inside">Inside</option>
<option value="outside">Outside</option>
</select>
</div>
{/* Social media presets */}
<div>
<label className="text-xs text-muted-foreground">Social Media Presets</label>
<select
onChange={(e) => {
const preset = SOCIAL_MEDIA_PRESETS.find(
(p) => `${p.platform} - ${p.name}` === e.target.value,
);
if (preset) handlePreset(preset.width, preset.height);
}}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
defaultValue=""
>
<option value="" disabled>
Choose a preset...
</option>
{platforms.map((platform) => (
<optgroup key={platform} label={platform}>
{SOCIAL_MEDIA_PRESETS.filter((p) => p.platform === platform).map((p) => (
<option key={`${p.platform}-${p.name}`} value={`${p.platform} - ${p.name}`}>
{p.name} ({p.width}x{p.height})
</option>
))}
</optgroup>
))}
</select>
</div>
</>
) : (
<div>
<label className="text-xs text-muted-foreground">Scale (%)</label>
<input
type="number"
value={percentage}
onChange={(e) => setPercentage(e.target.value)}
min={1}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
)}
{/* Don't enlarge */}
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={withoutEnlargement}
onChange={(e) => setWithoutEnlargement(e.target.checked)}
className="rounded"
/>
Don&apos;t enlarge
</label>
{/* Error */}
{error && (
<p className="text-xs text-red-500">{error}</p>
)}
{/* Size info */}
{originalSize != null && processedSize != null && (
<div className="text-xs text-muted-foreground space-y-0.5">
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
</div>
)}
{/* Process button */}
<button
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Processing..." : "Resize"}
</button>
{/* Download */}
{downloadUrl && (
<a
href={downloadUrl}
download
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
</a>
)}
</div>
);
}
@@ -0,0 +1,138 @@
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import {
Download,
Loader2,
RotateCcw,
RotateCw,
FlipHorizontal,
FlipVertical,
} from "lucide-react";
export function RotateSettings() {
const { files } = useFileStore();
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } =
useToolProcessor("rotate");
const [angle, setAngle] = useState(0);
const [flipH, setFlipH] = useState(false);
const [flipV, setFlipV] = useState(false);
const rotateLeft = () => setAngle((a) => (a - 90 + 360) % 360);
const rotateRight = () => setAngle((a) => (a + 90) % 360);
const handleProcess = () => {
processFiles(files, {
angle,
horizontal: flipH,
vertical: flipV,
});
};
const hasFile = files.length > 0;
const hasChanges = angle !== 0 || flipH || flipV;
return (
<div className="space-y-4">
{/* Quick rotate buttons */}
<div>
<label className="text-xs text-muted-foreground">Quick Rotate</label>
<div className="flex gap-2 mt-1">
<button
onClick={rotateLeft}
className="flex-1 flex items-center justify-center gap-1 py-2 rounded bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm"
>
<RotateCcw className="h-4 w-4" />
90 Left
</button>
<button
onClick={rotateRight}
className="flex-1 flex items-center justify-center gap-1 py-2 rounded bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm"
>
<RotateCw className="h-4 w-4" />
90 Right
</button>
</div>
</div>
{/* Angle slider */}
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Angle</label>
<span className="text-xs font-mono text-foreground">{angle} deg</span>
</div>
<input
type="range"
min={0}
max={360}
value={angle}
onChange={(e) => setAngle(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
{/* Flip buttons */}
<div>
<label className="text-xs text-muted-foreground">Flip</label>
<div className="flex gap-2 mt-1">
<button
onClick={() => setFlipH(!flipH)}
className={`flex-1 flex items-center justify-center gap-1 py-2 rounded text-sm transition-colors ${
flipH
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-primary/10"
}`}
>
<FlipHorizontal className="h-4 w-4" />
Horizontal
</button>
<button
onClick={() => setFlipV(!flipV)}
className={`flex-1 flex items-center justify-center gap-1 py-2 rounded text-sm transition-colors ${
flipV
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-primary/10"
}`}
>
<FlipVertical className="h-4 w-4" />
Vertical
</button>
</div>
</div>
{/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>}
{/* Size info */}
{originalSize != null && processedSize != null && (
<div className="text-xs text-muted-foreground space-y-0.5">
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
</div>
)}
{/* Process */}
<button
onClick={handleProcess}
disabled={!hasFile || !hasChanges || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Processing..." : "Rotate / Flip"}
</button>
{/* Download */}
{downloadUrl && (
<a
href={downloadUrl}
download
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
</a>
)}
</div>
);
}
@@ -0,0 +1,132 @@
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { Download, Loader2 } from "lucide-react";
export function StripMetadataSettings() {
const { files } = useFileStore();
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } =
useToolProcessor("strip-metadata");
const [stripAll, setStripAll] = useState(true);
const [stripExif, setStripExif] = useState(false);
const [stripGps, setStripGps] = useState(false);
const [stripIcc, setStripIcc] = useState(false);
const [stripXmp, setStripXmp] = useState(false);
const handleStripAllChange = (checked: boolean) => {
setStripAll(checked);
if (checked) {
setStripExif(false);
setStripGps(false);
setStripIcc(false);
setStripXmp(false);
}
};
const handleProcess = () => {
processFiles(files, { stripAll, stripExif, stripGps, stripIcc, stripXmp });
};
const hasFile = files.length > 0;
return (
<div className="space-y-4">
{/* Strip All */}
<label className="flex items-center gap-2 text-sm text-foreground font-medium">
<input
type="checkbox"
checked={stripAll}
onChange={(e) => handleStripAllChange(e.target.checked)}
className="rounded"
/>
Strip All Metadata
</label>
<div className="border-t border-border" />
{/* Individual options */}
<div className="space-y-2">
<label className="text-xs text-muted-foreground">Or select specific metadata:</label>
<label className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}>
<input
type="checkbox"
checked={stripExif}
onChange={(e) => setStripExif(e.target.checked)}
disabled={stripAll}
className="rounded"
/>
Strip EXIF (camera info, date, exposure)
</label>
<label className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}>
<input
type="checkbox"
checked={stripGps}
onChange={(e) => setStripGps(e.target.checked)}
disabled={stripAll}
className="rounded"
/>
Strip GPS (location data)
</label>
<label className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}>
<input
type="checkbox"
checked={stripIcc}
onChange={(e) => setStripIcc(e.target.checked)}
disabled={stripAll}
className="rounded"
/>
Strip ICC (color profile)
</label>
<label className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}>
<input
type="checkbox"
checked={stripXmp}
onChange={(e) => setStripXmp(e.target.checked)}
disabled={stripAll}
className="rounded"
/>
Strip XMP (extensible metadata)
</label>
</div>
{/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>}
{/* Size info */}
{originalSize != null && processedSize != null && (
<div className="text-xs text-muted-foreground space-y-0.5">
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
<p>Metadata removed: {((originalSize - processedSize) / 1024).toFixed(1)} KB</p>
</div>
)}
{/* Process */}
<button
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Processing..." : "Strip Metadata"}
</button>
{/* Download */}
{downloadUrl && (
<a
href={downloadUrl}
download
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
</a>
)}
</div>
);
}
+191
View File
@@ -0,0 +1,191 @@
import { useCallback, useState, useRef } from "react";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
}
interface BatchProgress {
totalFiles: number;
completedFiles: number;
failedFiles: number;
currentFile?: string;
errors: Array<{ filename: string; error: string }>;
status: "idle" | "uploading" | "processing" | "completed" | "failed";
/** Percentage 0-100. */
percent: number;
}
/**
* Hook for batch processing multiple files with SSE progress tracking.
*
* Uploads all files to the batch endpoint, listens for SSE progress events,
* and triggers a ZIP download when processing completes.
*/
export function useBatchProcessor(toolId: string) {
const [progress, setProgress] = useState<BatchProgress>({
totalFiles: 0,
completedFiles: 0,
failedFiles: 0,
errors: [],
status: "idle",
percent: 0,
});
const abortRef = useRef<AbortController | null>(null);
const processBatch = useCallback(
async (files: File[], settings: Record<string, unknown>) => {
if (files.length === 0) return;
// Reset state
setProgress({
totalFiles: files.length,
completedFiles: 0,
failedFiles: 0,
errors: [],
status: "uploading",
percent: 0,
});
abortRef.current = new AbortController();
try {
// Build multipart form with all files + settings
const formData = new FormData();
for (const file of files) {
formData.append("files", file);
}
formData.append("settings", JSON.stringify(settings));
setProgress((prev) => ({ ...prev, status: "processing" }));
const res = await fetch(`/api/v1/tools/${toolId}/batch`, {
method: "POST",
headers: { Authorization: `Bearer ${getToken()}` },
body: formData,
signal: abortRef.current.signal,
});
if (!res.ok) {
// Try to read error body
const text = await res.text();
let errorMsg = `Batch processing failed: ${res.status}`;
try {
const body = JSON.parse(text);
errorMsg = body.error || body.details || errorMsg;
} catch {
// ignore
}
setProgress((prev) => ({
...prev,
status: "failed",
errors: [{ filename: "", error: errorMsg }],
}));
return;
}
// Get the Job ID from the response header for SSE
const jobId = res.headers.get("X-Job-Id");
// The response IS the ZIP file — trigger download
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `batch-${toolId}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// If we have a jobId, try to get final progress from SSE
// But since the ZIP response already indicates success, mark as completed
setProgress((prev) => ({
...prev,
status: "completed",
completedFiles: files.length,
percent: 100,
}));
// Optionally fetch final progress for error details
if (jobId) {
try {
const progressRes = await fetch(`/api/v1/jobs/${jobId}/progress`, {
headers: { Authorization: `Bearer ${getToken()}` },
signal: AbortSignal.timeout(3000),
});
// SSE stream — read the last event
const reader = progressRes.body?.getReader();
if (reader) {
const decoder = new TextDecoder();
let buffer = "";
let lastData: string | null = null;
// Read a few chunks to get the final state
for (let i = 0; i < 5; i++) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
for (const line of lines) {
if (line.startsWith("data: ")) {
lastData = line.slice(6);
}
}
}
reader.cancel();
if (lastData) {
const finalProgress = JSON.parse(lastData);
setProgress((prev) => ({
...prev,
failedFiles: finalProgress.failedFiles ?? prev.failedFiles,
errors: finalProgress.errors ?? prev.errors,
completedFiles:
finalProgress.completedFiles ?? prev.completedFiles,
}));
}
}
} catch {
// Progress fetch is optional, ignore errors
}
}
} catch (err) {
if ((err as Error).name === "AbortError") return;
setProgress((prev) => ({
...prev,
status: "failed",
errors: [
{
filename: "",
error: err instanceof Error ? err.message : "Batch processing failed",
},
],
}));
}
},
[toolId],
);
const cancel = useCallback(() => {
abortRef.current?.abort();
setProgress((prev) => ({ ...prev, status: "idle" }));
}, []);
const reset = useCallback(() => {
setProgress({
totalFiles: 0,
completedFiles: 0,
failedFiles: 0,
errors: [],
status: "idle",
percent: 0,
});
}, []);
return {
processBatch,
cancel,
reset,
progress,
};
}
+81
View File
@@ -0,0 +1,81 @@
import { useCallback } from "react";
import { useFileStore } from "@/stores/file-store";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
}
interface ProcessResult {
jobId: string;
downloadUrl: string;
originalSize: number;
processedSize: number;
}
export function useToolProcessor(toolId: string) {
const {
processing,
error,
processedUrl,
originalSize,
processedSize,
setProcessing,
setError,
setProcessedUrl,
setSizes,
setJobId,
} = useFileStore();
const processFiles = useCallback(
async (files: File[], settings: Record<string, unknown>) => {
if (files.length === 0) {
setError("No files selected");
return;
}
setProcessing(true);
setError(null);
setProcessedUrl(null);
try {
// Build multipart form with the file and settings
const formData = new FormData();
formData.append("file", files[0]);
formData.append("settings", JSON.stringify(settings));
const res = await fetch(`/api/v1/tools/${toolId}`, {
method: "POST",
headers: { Authorization: `Bearer ${getToken()}` },
body: formData,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(
body.error || body.details || `Processing failed: ${res.status}`,
);
}
const result: ProcessResult = await res.json();
setJobId(result.jobId);
setProcessedUrl(result.downloadUrl);
setSizes(result.originalSize, result.processedSize);
} catch (err) {
setError(err instanceof Error ? err.message : "Processing failed");
} finally {
setProcessing(false);
}
},
[toolId, setProcessing, setError, setProcessedUrl, setSizes, setJobId],
);
return {
processFiles,
processing,
error,
downloadUrl: processedUrl,
originalSize,
processedSize,
};
}
+34
View File
@@ -32,3 +32,37 @@ export function setToken(token: string) {
export function clearToken() {
localStorage.removeItem("stirling-token");
}
// ── File Upload / Download ──────────────────────────────────────
export async function apiUpload(
files: File[],
): Promise<{
jobId: string;
files: Array<{ name: string; size: number; format: string }>;
}> {
const formData = new FormData();
files.forEach((f) => formData.append("files", f));
const res = await fetch("/api/v1/upload", {
method: "POST",
headers: { Authorization: `Bearer ${getToken()}` },
body: formData,
});
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
return res.json();
}
export function getDownloadUrl(jobId: string, filename: string): string {
return `/api/v1/download/${jobId}/${filename}`;
}
export async function apiDownloadBlob(
jobId: string,
filename: string,
): Promise<Blob> {
const res = await fetch(getDownloadUrl(jobId, filename), {
headers: { Authorization: `Bearer ${getToken()}` },
});
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
return res.blob();
}
+104 -21
View File
@@ -1,13 +1,54 @@
import { useParams } from "react-router-dom";
import { useMemo } from "react";
import { useMemo, useCallback } from "react";
import { TOOLS } from "@stirling-image/shared";
import { AppLayout } from "@/components/layout/app-layout";
import { Dropzone } from "@/components/common/dropzone";
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
import { useFileStore } from "@/stores/file-store";
import { ResizeSettings } from "@/components/tools/resize-settings";
import { CropSettings } from "@/components/tools/crop-settings";
import { RotateSettings } from "@/components/tools/rotate-settings";
import { ConvertSettings } from "@/components/tools/convert-settings";
import { CompressSettings } from "@/components/tools/compress-settings";
import { StripMetadataSettings } from "@/components/tools/strip-metadata-settings";
import { ColorSettings } from "@/components/tools/color-settings";
import * as icons from "lucide-react";
const COLOR_TOOL_IDS = new Set([
"brightness-contrast",
"saturation",
"color-channels",
"color-effects",
]);
function ToolSettingsPanel({ toolId }: { toolId: string }) {
if (toolId === "resize") return <ResizeSettings />;
if (toolId === "crop") return <CropSettings />;
if (toolId === "rotate") return <RotateSettings />;
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} />;
return (
<p className="text-xs text-muted-foreground italic">
Settings for this tool are coming soon.
</p>
);
}
export function ToolPage() {
const { toolId } = useParams<{ toolId: string }>();
const tool = useMemo(() => TOOLS.find((t) => t.id === toolId), [toolId]);
const { files, setFiles, reset, processedUrl, originalBlobUrl, originalSize, processedSize } = useFileStore();
const handleFiles = useCallback(
(newFiles: File[]) => {
reset();
setFiles(newFiles);
},
[setFiles, reset],
);
if (!tool) {
return (
@@ -19,7 +60,15 @@ export function ToolPage() {
);
}
const IconComponent = (icons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[tool.icon] || icons.FileImage;
const IconComponent =
(
icons as unknown as Record<
string,
React.ComponentType<{ className?: string }>
>
)[tool.icon] || icons.FileImage;
const hasFile = files.length > 0;
return (
<AppLayout showToolPanel={false}>
@@ -30,37 +79,71 @@ export function ToolPage() {
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
<IconComponent className="h-5 w-5" />
</div>
<h2 className="font-semibold text-lg text-foreground">{tool.name}</h2>
<h2 className="font-semibold text-lg text-foreground">
{tool.name}
</h2>
</div>
{/* File info */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">Files</h3>
<button className="flex items-center gap-2 text-sm text-primary hover:underline">
<icons.Upload className="h-4 w-4" />
Upload
</button>
<h3 className="text-sm font-medium text-muted-foreground">
Files
</h3>
{hasFile ? (
<div className="space-y-1">
{files.map((f, i) => (
<div
key={i}
className="flex items-center justify-between text-xs text-foreground bg-muted rounded px-2 py-1"
>
<span className="truncate">{f.name}</span>
<span className="text-muted-foreground shrink-0 ml-2">
{(f.size / 1024).toFixed(0)} KB
</span>
</div>
))}
<button
onClick={() => reset()}
className="text-xs text-muted-foreground hover:text-foreground"
>
Clear
</button>
</div>
) : (
<p className="text-xs text-muted-foreground italic">
Drop or upload an image to get started
</p>
)}
</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>
<p className="text-xs text-muted-foreground italic">{tool.description}</p>
<h3 className="text-sm font-medium text-muted-foreground">
Settings
</h3>
<ToolSettingsPanel toolId={tool.id} />
</div>
<div className="border-t border-border" />
<button
disabled
className="w-full py-2.5 rounded-lg bg-muted text-muted-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{tool.name}
</button>
</div>
{/* Dropzone */}
{/* Dropzone / Preview */}
<div className="flex-1 flex items-center justify-center p-6">
<Dropzone />
{processedUrl && originalBlobUrl ? (
<BeforeAfterSlider
beforeSrc={originalBlobUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
) : (
<Dropzone
onFiles={handleFiles}
accept="image/*"
multiple
currentFiles={files}
/>
)}
</div>
</div>
</AppLayout>
+61
View File
@@ -0,0 +1,61 @@
import { create } from "zustand";
interface FileState {
files: File[];
jobId: string | null;
processedUrl: string | null;
/** Blob URL for the original image (for before/after comparison). */
originalBlobUrl: string | null;
processing: boolean;
error: string | null;
originalSize: number | null;
processedSize: number | null;
setFiles: (files: File[]) => void;
setJobId: (id: string) => void;
setProcessedUrl: (url: string | null) => void;
setOriginalBlobUrl: (url: string | null) => void;
setProcessing: (v: boolean) => void;
setError: (e: string | null) => void;
setSizes: (original: number, processed: number) => void;
reset: () => void;
}
export const useFileStore = create<FileState>((set, get) => ({
files: [],
jobId: null,
processedUrl: null,
originalBlobUrl: null,
processing: false,
error: null,
originalSize: null,
processedSize: null,
setFiles: (files) => {
// Revoke old blob URL if any
const old = get().originalBlobUrl;
if (old) URL.revokeObjectURL(old);
// Create a blob URL for the first file for before/after preview
const blobUrl = files.length > 0 ? URL.createObjectURL(files[0]) : null;
set({ files, error: null, originalBlobUrl: blobUrl });
},
setJobId: (id) => set({ jobId: id }),
setProcessedUrl: (url) => set({ processedUrl: url }),
setOriginalBlobUrl: (url) => set({ originalBlobUrl: url }),
setProcessing: (v) => set({ processing: v }),
setError: (e) => set({ error: e, processing: false }),
setSizes: (original, processed) =>
set({ originalSize: original, processedSize: processed }),
reset: () => {
const old = get().originalBlobUrl;
if (old) URL.revokeObjectURL(old);
set({
files: [],
jobId: null,
processedUrl: null,
originalBlobUrl: null,
processing: false,
error: null,
originalSize: null,
processedSize: null,
});
},
}));
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -7,12 +7,15 @@
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
"@stirling-image/shared": "workspace:*"
"@stirling-image/shared": "workspace:*",
"sharp": "^0.33.0"
},
"devDependencies": {
"typescript": "^5.7.0"
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}
+107
View File
@@ -0,0 +1,107 @@
import sharp from "sharp";
import type {
OperationResult,
OutputFormat,
Sharp,
ResizeOptions,
CropOptions,
RotateOptions,
FlipOptions,
ConvertOptions,
CompressOptions,
StripMetadataOptions,
BrightnessOptions,
ContrastOptions,
SaturationOptions,
ColorChannelOptions,
} from "./types.js";
import { resize } from "./operations/resize.js";
import { crop } from "./operations/crop.js";
import { rotate } from "./operations/rotate.js";
import { flip } from "./operations/flip.js";
import { convert } from "./operations/convert.js";
import { compress } from "./operations/compress.js";
import { stripMetadata } from "./operations/strip-metadata.js";
import { brightness } from "./operations/brightness.js";
import { contrast } from "./operations/contrast.js";
import { saturation } from "./operations/saturation.js";
import { colorChannels } from "./operations/color-channels.js";
import { grayscale } from "./operations/grayscale.js";
import { sepia } from "./operations/sepia.js";
import { invert } from "./operations/invert.js";
import { getImageInfo } from "./utils/metadata.js";
export interface Operation {
type: string;
options: Record<string, unknown>;
}
const OPERATION_MAP: Record<
string,
(image: Sharp, options: Record<string, unknown>) => Promise<Sharp>
> = {
resize: (img, opts) => resize(img, opts as unknown as ResizeOptions),
crop: (img, opts) => crop(img, opts as unknown as CropOptions),
rotate: (img, opts) => rotate(img, opts as unknown as RotateOptions),
flip: (img, opts) => flip(img, opts as unknown as FlipOptions),
convert: (img, opts) => convert(img, opts as unknown as ConvertOptions),
compress: (img, opts) => compress(img, opts as unknown as CompressOptions),
"strip-metadata": (img, opts) =>
stripMetadata(img, opts as unknown as StripMetadataOptions),
brightness: (img, opts) => brightness(img, opts as unknown as BrightnessOptions),
contrast: (img, opts) => contrast(img, opts as unknown as ContrastOptions),
saturation: (img, opts) => saturation(img, opts as unknown as SaturationOptions),
"color-channels": (img, opts) =>
colorChannels(img, opts as unknown as ColorChannelOptions),
grayscale: (img) => grayscale(img),
sepia: (img) => sepia(img),
invert: (img) => invert(img),
};
const FORMAT_MAP: Record<OutputFormat, string> = {
jpg: "jpeg",
png: "png",
webp: "webp",
avif: "avif",
tiff: "tiff",
gif: "gif",
};
/**
* Process an image through a pipeline of operations.
*
* @param input - The raw image buffer
* @param operations - Array of operations to apply in sequence
* @param outputFormat - Optional output format (defaults to input format)
* @returns The processed image buffer and metadata
*/
export async function processImage(
input: Buffer,
operations: Operation[],
outputFormat?: OutputFormat
): Promise<OperationResult> {
let image: Sharp = sharp(input);
// Apply each operation in sequence
for (const op of operations) {
const handler = OPERATION_MAP[op.type];
if (!handler) {
throw new Error(`Unknown operation: ${op.type}`);
}
image = await handler(image, op.options);
}
// Convert to output format if specified
if (outputFormat) {
const sharpFormat = FORMAT_MAP[outputFormat];
if (!sharpFormat) {
throw new Error(`Unsupported output format: ${outputFormat}`);
}
image = image.toFormat(sharpFormat as keyof import("sharp").FormatEnum);
}
const buffer = await image.toBuffer();
const info = await getImageInfo(buffer);
return { buffer, info };
}
@@ -0,0 +1,57 @@
import sharp from "sharp";
const MAGIC_BYTES: Array<{ bytes: number[]; offset: number; format: string }> = [
{ bytes: [0x89, 0x50, 0x4e, 0x47], offset: 0, format: "png" },
{ bytes: [0xff, 0xd8, 0xff], offset: 0, format: "jpeg" },
{ bytes: [0x47, 0x49, 0x46, 0x38], offset: 0, format: "gif" },
{ bytes: [0x52, 0x49, 0x46, 0x46], offset: 0, format: "webp" }, // RIFF header (check WEBP after)
{ bytes: [0x49, 0x49, 0x2a, 0x00], offset: 0, format: "tiff" }, // Little-endian TIFF
{ bytes: [0x4d, 0x4d, 0x00, 0x2a], offset: 0, format: "tiff" }, // Big-endian TIFF
{ bytes: [0x42, 0x4d], offset: 0, format: "bmp" },
];
/**
* Detect the image format from a buffer.
* Uses Sharp metadata first, falls back to magic byte detection.
*/
export async function detectFormat(buffer: Buffer): Promise<string> {
try {
const metadata = await sharp(buffer).metadata();
if (metadata.format) {
return metadata.format;
}
} catch {
// Sharp couldn't parse it; fall through to magic bytes
}
return detectByMagicBytes(buffer);
}
function detectByMagicBytes(buffer: Buffer): string {
for (const entry of MAGIC_BYTES) {
if (buffer.length < entry.offset + entry.bytes.length) {
continue;
}
let match = true;
for (let i = 0; i < entry.bytes.length; i++) {
if (buffer[entry.offset + i] !== entry.bytes[i]) {
match = false;
break;
}
}
if (match) {
// For RIFF, verify it's actually WEBP
if (entry.format === "webp" && buffer.length >= 12) {
const webpSignature = buffer.slice(8, 12).toString("ascii");
if (webpSignature !== "WEBP") {
continue;
}
}
return entry.format;
}
}
return "unknown";
}
+19 -1
View File
@@ -1 +1,19 @@
export const IMAGE_ENGINE_VERSION = "0.0.1";
export * from "./types.js";
export * from "./engine.js";
export * from "./formats/detect.js";
export * from "./utils/metadata.js";
export * from "./utils/mime.js";
export { resize } from "./operations/resize.js";
export { crop } from "./operations/crop.js";
export { rotate } from "./operations/rotate.js";
export { flip } from "./operations/flip.js";
export { convert } from "./operations/convert.js";
export { compress } from "./operations/compress.js";
export { stripMetadata } from "./operations/strip-metadata.js";
export { brightness } from "./operations/brightness.js";
export { contrast } from "./operations/contrast.js";
export { saturation } from "./operations/saturation.js";
export { colorChannels } from "./operations/color-channels.js";
export { grayscale } from "./operations/grayscale.js";
export { sepia } from "./operations/sepia.js";
export { invert } from "./operations/invert.js";
@@ -0,0 +1,15 @@
import type { Sharp, BrightnessOptions } from "../types.js";
export async function brightness(image: Sharp, options: BrightnessOptions): Promise<Sharp> {
const { value } = options;
if (value < -100 || value > 100) {
throw new Error("Brightness value must be between -100 and +100");
}
// Map -100..+100 to 0..2 where 1.0 = no change
// -100 -> 0, 0 -> 1, +100 -> 2
const multiplier = 1 + value / 100;
return image.modulate({ brightness: multiplier });
}
@@ -0,0 +1,35 @@
import type { Sharp, ColorChannelOptions } from "../types.js";
export async function colorChannels(
image: Sharp,
options: ColorChannelOptions
): Promise<Sharp> {
const { red, green, blue } = options;
if (red < 0 || red > 200) {
throw new Error("Red channel value must be between 0 and 200");
}
if (green < 0 || green > 200) {
throw new Error("Green channel value must be between 0 and 200");
}
if (blue < 0 || blue > 200) {
throw new Error("Blue channel value must be between 0 and 200");
}
// Map 0-200 to 0-2 multipliers on the diagonal of a 3x3 recomb matrix
const rMul = red / 100;
const gMul = green / 100;
const bMul = blue / 100;
const matrix: [
[number, number, number],
[number, number, number],
[number, number, number],
] = [
[rMul, 0, 0],
[0, gMul, 0],
[0, 0, bMul],
];
return image.recomb(matrix);
}
@@ -0,0 +1,80 @@
import sharp from "sharp";
import type { Sharp, CompressOptions, OutputFormat } from "../types.js";
const FORMAT_MAP: Record<OutputFormat, string> = {
jpg: "jpeg",
png: "png",
webp: "webp",
avif: "avif",
tiff: "tiff",
gif: "gif",
};
export async function compress(image: Sharp, options: CompressOptions): Promise<Sharp> {
const { quality, targetSizeBytes, format } = options;
const metadata = await image.metadata();
const outputFormat = format
? (FORMAT_MAP[format] as keyof import("sharp").FormatEnum)
: (metadata.format as keyof import("sharp").FormatEnum) ?? "jpeg";
if (targetSizeBytes !== undefined) {
if (targetSizeBytes <= 0) {
throw new Error("Target size must be greater than 0");
}
return compressToTargetSize(image, outputFormat, targetSizeBytes);
}
const q = quality ?? 80;
if (q < 1 || q > 100) {
throw new Error("Quality must be between 1 and 100");
}
return image.toFormat(outputFormat, { quality: q });
}
async function compressToTargetSize(
image: Sharp,
format: keyof import("sharp").FormatEnum,
targetBytes: number
): Promise<Sharp> {
// Get the raw buffer to re-create Sharp instances for each attempt
const inputBuffer = await image.toBuffer();
let low = 1;
let high = 100;
let bestQuality = 80;
let bestBuffer: Buffer | null = null;
const maxIterations = 8;
const tolerance = 0.05; // 5%
for (let i = 0; i < maxIterations && low <= high; i++) {
const mid = Math.min(100, Math.max(1, Math.round((low + high) / 2)));
const attempt = sharp(inputBuffer).toFormat(format, { quality: mid });
const resultBuffer = await attempt.toBuffer();
const resultSize = resultBuffer.length;
if (Math.abs(resultSize - targetBytes) / targetBytes <= tolerance) {
bestQuality = mid;
bestBuffer = resultBuffer;
break;
}
if (resultSize > targetBytes) {
high = mid - 1;
} else {
low = mid + 1;
bestQuality = mid;
bestBuffer = resultBuffer;
}
}
// If we never found a suitable buffer, compress at the best quality we found
if (bestBuffer === null) {
bestBuffer = await sharp(inputBuffer)
.toFormat(format, { quality: bestQuality })
.toBuffer();
}
return sharp(bestBuffer);
}
@@ -0,0 +1,17 @@
import type { Sharp, ContrastOptions } from "../types.js";
export async function contrast(image: Sharp, options: ContrastOptions): Promise<Sharp> {
const { value } = options;
if (value < -100 || value > 100) {
throw new Error("Contrast value must be between -100 and +100");
}
// Map -100..+100 to linear transform
// slope = 1 + (value/100), e.g. -100 -> 0, 0 -> 1, +100 -> 2
// intercept centers the adjustment around middle gray (128)
const slope = 1 + value / 100;
const intercept = 128 * (1 - slope);
return image.linear(slope, intercept);
}
@@ -0,0 +1,29 @@
import type { Sharp, ConvertOptions, OutputFormat } from "../types.js";
const FORMAT_MAP: Record<OutputFormat, string> = {
jpg: "jpeg",
png: "png",
webp: "webp",
avif: "avif",
tiff: "tiff",
gif: "gif",
};
export async function convert(image: Sharp, options: ConvertOptions): Promise<Sharp> {
const { format, quality } = options;
const sharpFormat = FORMAT_MAP[format];
if (!sharpFormat) {
throw new Error(`Unsupported output format: ${format}`);
}
const formatOptions: Record<string, unknown> = {};
if (quality !== undefined) {
if (quality < 1 || quality > 100) {
throw new Error("Quality must be between 1 and 100");
}
formatOptions.quality = quality;
}
return image.toFormat(sharpFormat as keyof import("sharp").FormatEnum, formatOptions);
}
@@ -0,0 +1,29 @@
import type { Sharp, CropOptions } from "../types.js";
export async function crop(image: Sharp, options: CropOptions): Promise<Sharp> {
const { left, top, width, height } = options;
if (width <= 0 || height <= 0) {
throw new Error("Crop width and height must be greater than 0");
}
if (left < 0 || top < 0) {
throw new Error("Crop left and top must be non-negative");
}
const metadata = await image.metadata();
const imgWidth = metadata.width ?? 0;
const imgHeight = metadata.height ?? 0;
if (left + width > imgWidth) {
throw new Error(
`Crop region exceeds image width: left(${left}) + width(${width}) > ${imgWidth}`
);
}
if (top + height > imgHeight) {
throw new Error(
`Crop region exceeds image height: top(${top}) + height(${height}) > ${imgHeight}`
);
}
return image.extract({ left, top, width, height });
}
@@ -0,0 +1,21 @@
import type { Sharp, FlipOptions } from "../types.js";
export async function flip(image: Sharp, options: FlipOptions): Promise<Sharp> {
const { horizontal, vertical } = options;
if (!horizontal && !vertical) {
throw new Error("Flip requires at least one of horizontal or vertical");
}
let result = image;
if (horizontal) {
result = result.flop();
}
if (vertical) {
result = result.flip();
}
return result;
}
@@ -0,0 +1,5 @@
import type { Sharp } from "../types.js";
export async function grayscale(image: Sharp): Promise<Sharp> {
return image.grayscale();
}
@@ -0,0 +1,5 @@
import type { Sharp } from "../types.js";
export async function invert(image: Sharp): Promise<Sharp> {
return image.negate();
}
@@ -0,0 +1,33 @@
import type { Sharp, ResizeOptions } from "../types.js";
export async function resize(image: Sharp, options: ResizeOptions): Promise<Sharp> {
let { width, height, fit, withoutEnlargement, percentage } = options;
if (percentage !== undefined) {
if (percentage <= 0) {
throw new Error("Resize percentage must be greater than 0");
}
const metadata = await image.metadata();
const currentWidth = metadata.width ?? 0;
const currentHeight = metadata.height ?? 0;
width = Math.round(currentWidth * (percentage / 100));
height = Math.round(currentHeight * (percentage / 100));
}
if (width !== undefined && width <= 0) {
throw new Error("Resize width must be greater than 0");
}
if (height !== undefined && height <= 0) {
throw new Error("Resize height must be greater than 0");
}
if (width === undefined && height === undefined) {
throw new Error("Resize requires width, height, or percentage");
}
return image.resize({
width,
height,
fit: fit ?? "cover",
withoutEnlargement: withoutEnlargement ?? false,
});
}
@@ -0,0 +1,15 @@
import type { Sharp, RotateOptions } from "../types.js";
export async function rotate(image: Sharp, options: RotateOptions): Promise<Sharp> {
const { angle, background } = options;
const isMultipleOf90 = angle % 90 === 0;
if (isMultipleOf90) {
return image.rotate(angle);
}
return image.rotate(angle, {
background: background ?? "#000000",
});
}
@@ -0,0 +1,15 @@
import type { Sharp, SaturationOptions } from "../types.js";
export async function saturation(image: Sharp, options: SaturationOptions): Promise<Sharp> {
const { value } = options;
if (value < -100 || value > 100) {
throw new Error("Saturation value must be between -100 and +100");
}
// Map -100..+100 to 0..2 where 1.0 = no change
// -100 -> 0 (grayscale), 0 -> 1 (no change), +100 -> 2 (double saturation)
const multiplier = 1 + value / 100;
return image.modulate({ saturation: multiplier });
}
@@ -0,0 +1,16 @@
import type { Sharp } from "../types.js";
// Standard sepia tone matrix
const SEPIA_MATRIX: [
[number, number, number],
[number, number, number],
[number, number, number],
] = [
[0.393, 0.769, 0.189],
[0.349, 0.686, 0.168],
[0.272, 0.534, 0.131],
];
export async function sepia(image: Sharp): Promise<Sharp> {
return image.recomb(SEPIA_MATRIX);
}
@@ -0,0 +1,38 @@
import type { Sharp, StripMetadataOptions } from "../types.js";
export async function stripMetadata(
image: Sharp,
options: StripMetadataOptions = {}
): Promise<Sharp> {
const { stripExif, stripGps, stripIcc, stripXmp, stripAll } = options;
// Default behavior: strip all metadata
const shouldStripAll =
stripAll === true ||
(stripExif === undefined &&
stripGps === undefined &&
stripIcc === undefined &&
stripXmp === undefined &&
stripAll === undefined);
if (shouldStripAll) {
// withMetadata({}) with no options strips everything;
// but to truly strip we avoid calling withMetadata at all.
// Sharp strips metadata by default when outputting.
// Calling .withMetadata() KEEPS metadata, so we do NOT call it.
return image;
}
// Selective stripping: we keep metadata but remove specific fields.
// Sharp's withMetadata lets us keep ICC, EXIF, etc.
// We call withMetadata to keep what wasn't requested stripped.
const keepIcc = !stripIcc;
return image.withMetadata({
// If we want to keep ICC, pass undefined (Sharp default keeps it with withMetadata)
// If we want to strip ICC, we need to not call withMetadata at all or handle differently
// Sharp's withMetadata keeps metadata; without it, metadata is stripped.
// For selective stripping, we strip all first then re-add what we want to keep.
...(keepIcc ? {} : {}),
});
}
+82
View File
@@ -0,0 +1,82 @@
import type sharp from "sharp";
export type Sharp = sharp.Sharp;
export interface ImageInfo {
width: number;
height: number;
format: string;
channels: number;
size: number;
hasAlpha: boolean;
metadata: Record<string, unknown>;
}
export interface OperationResult {
buffer: Buffer;
info: ImageInfo;
}
export type OutputFormat = "jpg" | "png" | "webp" | "avif" | "tiff" | "gif";
export interface ResizeOptions {
width?: number;
height?: number;
fit?: "contain" | "cover" | "fill" | "inside" | "outside";
withoutEnlargement?: boolean;
percentage?: number;
}
export interface CropOptions {
left: number;
top: number;
width: number;
height: number;
}
export interface RotateOptions {
angle: number;
background?: string;
}
export interface FlipOptions {
horizontal?: boolean;
vertical?: boolean;
}
export interface ConvertOptions {
format: OutputFormat;
quality?: number;
}
export interface CompressOptions {
quality?: number;
targetSizeBytes?: number;
format?: OutputFormat;
}
export interface StripMetadataOptions {
stripExif?: boolean;
stripGps?: boolean;
stripIcc?: boolean;
stripXmp?: boolean;
stripAll?: boolean;
}
export interface BrightnessOptions {
value: number; // -100 to +100
}
export interface ContrastOptions {
value: number; // -100 to +100
}
export interface SaturationOptions {
value: number; // -100 to +100
}
export interface ColorChannelOptions {
red: number; // 0-200 (100 = no change)
green: number; // 0-200
blue: number; // 0-200
}
@@ -0,0 +1,28 @@
import sharp from "sharp";
import type { ImageInfo } from "../types.js";
/**
* Extract comprehensive image metadata from a buffer.
*/
export async function getImageInfo(buffer: Buffer): Promise<ImageInfo> {
const metadata = await sharp(buffer).metadata();
return {
width: metadata.width ?? 0,
height: metadata.height ?? 0,
format: metadata.format ?? "unknown",
channels: metadata.channels ?? 0,
size: buffer.length,
hasAlpha: metadata.hasAlpha ?? false,
metadata: {
space: metadata.space,
density: metadata.density,
isProgressive: metadata.isProgressive,
hasProfile: metadata.hasProfile,
orientation: metadata.orientation,
exif: metadata.exif ? true : false,
icc: metadata.icc ? true : false,
xmp: metadata.xmp ? true : false,
},
};
}
+63
View File
@@ -0,0 +1,63 @@
const EXT_TO_MIME: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
avif: "image/avif",
tiff: "image/tiff",
tif: "image/tiff",
gif: "image/gif",
bmp: "image/bmp",
svg: "image/svg+xml",
ico: "image/x-icon",
heif: "image/heif",
heic: "image/heic",
};
const MIME_TO_EXT: Record<string, string> = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/avif": "avif",
"image/tiff": "tiff",
"image/gif": "gif",
"image/bmp": "bmp",
"image/svg+xml": "svg",
"image/x-icon": "ico",
"image/heif": "heif",
"image/heic": "heic",
};
/**
* Get the MIME type for a file extension (without dot).
*/
export function extToMime(ext: string): string {
const normalized = ext.toLowerCase().replace(/^\./, "");
return EXT_TO_MIME[normalized] ?? "application/octet-stream";
}
/**
* Get the file extension for a MIME type (without dot).
*/
export function mimeToExt(mime: string): string {
const normalized = mime.toLowerCase();
return MIME_TO_EXT[normalized] ?? "bin";
}
/**
* Get the MIME type for a Sharp format string.
*/
export function formatToMime(format: string): string {
const normalized = format.toLowerCase();
if (normalized === "jpeg") return "image/jpeg";
return EXT_TO_MIME[normalized] ?? "application/octet-stream";
}
/**
* Get the file extension for a Sharp format string.
*/
export function formatToExt(format: string): string {
const normalized = format.toLowerCase();
if (normalized === "jpeg") return "jpg";
return normalized;
}
@@ -0,0 +1,390 @@
import { describe, it, expect, beforeAll } from "vitest";
import sharp from "sharp";
import { resize } from "../src/operations/resize.js";
import { crop } from "../src/operations/crop.js";
import { rotate } from "../src/operations/rotate.js";
import { flip } from "../src/operations/flip.js";
import { convert } from "../src/operations/convert.js";
import { compress } from "../src/operations/compress.js";
import { grayscale } from "../src/operations/grayscale.js";
import { sepia } from "../src/operations/sepia.js";
import { invert } from "../src/operations/invert.js";
import { brightness } from "../src/operations/brightness.js";
import { contrast } from "../src/operations/contrast.js";
import { saturation } from "../src/operations/saturation.js";
import { colorChannels } from "../src/operations/color-channels.js";
import { stripMetadata } from "../src/operations/strip-metadata.js";
import { processImage } from "../src/engine.js";
import { detectFormat } from "../src/formats/detect.js";
import { getImageInfo } from "../src/utils/metadata.js";
import { extToMime, mimeToExt, formatToMime, formatToExt } from "../src/utils/mime.js";
// Generate a 100x100 red PNG buffer for testing
let testBuffer: Buffer;
let testImage: () => sharp.Sharp;
beforeAll(async () => {
testBuffer = await sharp({
create: {
width: 100,
height: 100,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
})
.png()
.toBuffer();
testImage = () => sharp(testBuffer);
});
describe("resize", () => {
it("should resize to 50x50", async () => {
const result = await resize(testImage(), { width: 50, height: 50 });
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
expect(meta.width).toBe(50);
expect(meta.height).toBe(50);
});
it("should resize by percentage", async () => {
const result = await resize(testImage(), { percentage: 50 });
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
expect(meta.width).toBe(50);
expect(meta.height).toBe(50);
});
it("should throw on zero width", async () => {
await expect(resize(testImage(), { width: 0 })).rejects.toThrow();
});
it("should throw when no dimensions provided", async () => {
await expect(resize(testImage(), {})).rejects.toThrow();
});
});
describe("crop", () => {
it("should crop 25x25 at (10,10)", async () => {
const result = await crop(testImage(), {
left: 10,
top: 10,
width: 25,
height: 25,
});
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
expect(meta.width).toBe(25);
expect(meta.height).toBe(25);
});
it("should throw on out-of-bounds crop", async () => {
await expect(
crop(testImage(), { left: 90, top: 90, width: 20, height: 20 })
).rejects.toThrow();
});
it("should throw on zero dimensions", async () => {
await expect(
crop(testImage(), { left: 0, top: 0, width: 0, height: 10 })
).rejects.toThrow();
});
});
describe("rotate", () => {
it("should rotate 90 degrees and swap dimensions", async () => {
// Create a non-square image to verify dimension swap
const rectBuffer = await sharp({
create: {
width: 100,
height: 50,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
})
.png()
.toBuffer();
const result = await rotate(sharp(rectBuffer), { angle: 90 });
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
expect(meta.width).toBe(50);
expect(meta.height).toBe(100);
});
it("should rotate non-90 angle with background", async () => {
const result = await rotate(testImage(), {
angle: 45,
background: "#FF0000",
});
const meta = await result.metadata();
expect(meta.width).toBeGreaterThan(0);
expect(meta.height).toBeGreaterThan(0);
});
});
describe("flip", () => {
it("should flip horizontally without error", async () => {
const result = await flip(testImage(), { horizontal: true });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("should flip vertically without error", async () => {
const result = await flip(testImage(), { vertical: true });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("should flip both directions", async () => {
const result = await flip(testImage(), { horizontal: true, vertical: true });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("should throw when neither direction specified", async () => {
await expect(flip(testImage(), {})).rejects.toThrow();
});
});
describe("convert", () => {
it("should convert to webp", async () => {
const result = await convert(testImage(), { format: "webp" });
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
expect(meta.format).toBe("webp");
});
it("should convert to jpg with quality", async () => {
const result = await convert(testImage(), { format: "jpg", quality: 80 });
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
expect(meta.format).toBe("jpeg");
});
it("should throw on invalid quality", async () => {
await expect(
convert(testImage(), { format: "png", quality: 0 })
).rejects.toThrow();
});
});
describe("compress", () => {
it("should compress at quality 50 and produce smaller output", async () => {
// Use a larger image for better compression ratio visibility
const largeBuffer = await sharp({
create: {
width: 500,
height: 500,
channels: 3,
background: { r: 255, g: 128, b: 0 },
},
})
.jpeg({ quality: 100 })
.toBuffer();
const result = await compress(sharp(largeBuffer), {
quality: 50,
format: "jpg",
});
const buf = await result.toBuffer();
expect(buf.length).toBeLessThan(largeBuffer.length);
});
it("should throw on invalid quality", async () => {
await expect(
compress(testImage(), { quality: 0 })
).rejects.toThrow();
});
it("should compress to target size", async () => {
const largeBuffer = await sharp({
create: {
width: 500,
height: 500,
channels: 3,
background: { r: 255, g: 128, b: 0 },
},
})
.jpeg({ quality: 100 })
.toBuffer();
const targetSize = Math.round(largeBuffer.length * 0.5);
const result = await compress(sharp(largeBuffer), {
targetSizeBytes: targetSize,
format: "jpg",
});
const buf = await result.toBuffer();
// Should be reasonably close to target (within 50% tolerance for small images)
expect(buf.length).toBeLessThan(largeBuffer.length);
});
});
describe("grayscale", () => {
it("should convert to grayscale", async () => {
const result = await grayscale(testImage());
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
// Grayscale PNG may still report channels as 3 or 1 depending on output
expect(buf.length).toBeGreaterThan(0);
// The image should have no color variation
expect(meta.width).toBe(100);
});
});
describe("sepia", () => {
it("should apply sepia tone without error", async () => {
const result = await sepia(testImage());
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
});
describe("invert", () => {
it("should invert colors without error", async () => {
const result = await invert(testImage());
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
});
describe("brightness", () => {
it("should adjust brightness +50 without error", async () => {
const result = await brightness(testImage(), { value: 50 });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("should throw on out-of-range value", async () => {
await expect(brightness(testImage(), { value: 150 })).rejects.toThrow();
});
});
describe("contrast", () => {
it("should adjust contrast +50 without error", async () => {
const result = await contrast(testImage(), { value: 50 });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("should throw on out-of-range value", async () => {
await expect(contrast(testImage(), { value: -150 })).rejects.toThrow();
});
});
describe("saturation", () => {
it("should adjust saturation -50 without error", async () => {
const result = await saturation(testImage(), { value: -50 });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("should throw on out-of-range value", async () => {
await expect(saturation(testImage(), { value: 200 })).rejects.toThrow();
});
});
describe("colorChannels", () => {
it("should adjust color channels without error", async () => {
const result = await colorChannels(testImage(), {
red: 150,
green: 100,
blue: 50,
});
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("should throw on out-of-range values", async () => {
await expect(
colorChannels(testImage(), { red: 250, green: 100, blue: 100 })
).rejects.toThrow();
});
});
describe("stripMetadata", () => {
it("should strip metadata without error", async () => {
const result = await stripMetadata(testImage());
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
});
describe("processImage (engine)", () => {
it("should apply multiple operations in sequence", async () => {
const result = await processImage(
testBuffer,
[
{ type: "resize", options: { width: 50, height: 50 } },
{ type: "grayscale", options: {} },
],
"png"
);
expect(result.info.width).toBe(50);
expect(result.info.height).toBe(50);
expect(result.buffer.length).toBeGreaterThan(0);
});
it("should throw on unknown operation", async () => {
await expect(
processImage(testBuffer, [{ type: "unknown-op", options: {} }])
).rejects.toThrow("Unknown operation");
});
it("should convert output format", async () => {
const result = await processImage(testBuffer, [], "webp");
expect(result.info.format).toBe("webp");
});
});
describe("detectFormat", () => {
it("should detect PNG format", async () => {
const format = await detectFormat(testBuffer);
expect(format).toBe("png");
});
it("should detect JPEG format", async () => {
const jpegBuffer = await sharp(testBuffer).jpeg().toBuffer();
const format = await detectFormat(jpegBuffer);
expect(format).toBe("jpeg");
});
});
describe("getImageInfo", () => {
it("should return correct image info", async () => {
const info = await getImageInfo(testBuffer);
expect(info.width).toBe(100);
expect(info.height).toBe(100);
expect(info.format).toBe("png");
expect(info.channels).toBe(3);
expect(info.size).toBeGreaterThan(0);
expect(info.hasAlpha).toBe(false);
});
});
describe("mime utilities", () => {
it("should map extension to MIME type", () => {
expect(extToMime("jpg")).toBe("image/jpeg");
expect(extToMime("png")).toBe("image/png");
expect(extToMime("webp")).toBe("image/webp");
expect(extToMime(".jpg")).toBe("image/jpeg");
expect(extToMime("unknown")).toBe("application/octet-stream");
});
it("should map MIME type to extension", () => {
expect(mimeToExt("image/jpeg")).toBe("jpg");
expect(mimeToExt("image/png")).toBe("png");
expect(mimeToExt("application/unknown")).toBe("bin");
});
it("should map format to MIME type", () => {
expect(formatToMime("jpeg")).toBe("image/jpeg");
expect(formatToMime("png")).toBe("image/png");
});
it("should map format to extension", () => {
expect(formatToExt("jpeg")).toBe("jpg");
expect(formatToExt("png")).toBe("png");
});
});
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
},
});
+1162
View File
File diff suppressed because it is too large Load Diff