mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Merge pull request #2 from siddharthksah/phase2-core-tools
Phase 2: Core Tools - Image Engine, 10 Tools, Batch Processing
This commit is contained in:
+14
-9
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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" };
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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" };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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" };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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)");
|
||||
}
|
||||
@@ -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" };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 ? {} : {}),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
Generated
+1162
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user