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"
|
"clean": "rm -rf dist"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/cors": "^11.0.0",
|
||||||
|
"@fastify/multipart": "^9.0.0",
|
||||||
"@fastify/rate-limit": "^10.2.0",
|
"@fastify/rate-limit": "^10.2.0",
|
||||||
|
"@fastify/static": "^8.1.0",
|
||||||
"@fastify/swagger": "^9.4.0",
|
"@fastify/swagger": "^9.4.0",
|
||||||
"@fastify/swagger-ui": "^5.2.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",
|
"dotenv": "^16.4.0",
|
||||||
"zod": "^3.24.0",
|
|
||||||
"drizzle-orm": "^0.38.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": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.0",
|
"@types/archiver": "^7.0.0",
|
||||||
"tsx": "^4.19.0",
|
"@types/better-sqlite3": "^7.6.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"drizzle-kit": "^0.30.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 { APP_VERSION } from "@stirling-image/shared";
|
||||||
import { runMigrations } from "./db/migrate.js";
|
import { runMigrations } from "./db/migrate.js";
|
||||||
import { ensureDefaultAdmin, authRoutes, authMiddleware } from "./plugins/auth.js";
|
import { ensureDefaultAdmin, authRoutes, authMiddleware } from "./plugins/auth.js";
|
||||||
|
import { registerUpload } from "./plugins/upload.js";
|
||||||
import { registerStatic } from "./plugins/static.js";
|
import { registerStatic } from "./plugins/static.js";
|
||||||
import { startCleanupCron } from "./lib/cleanup.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
|
// Run before anything else
|
||||||
runMigrations();
|
runMigrations();
|
||||||
@@ -45,12 +50,27 @@ await app.register(swaggerUi, {
|
|||||||
routePrefix: "/api/docs",
|
routePrefix: "/api/docs",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Multipart upload support
|
||||||
|
await registerUpload(app);
|
||||||
|
|
||||||
// Auth middleware (must be registered before routes it protects)
|
// Auth middleware (must be registered before routes it protects)
|
||||||
await authMiddleware(app);
|
await authMiddleware(app);
|
||||||
|
|
||||||
// Auth routes
|
// Auth routes
|
||||||
await authRoutes(app);
|
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
|
// Health check
|
||||||
app.get("/api/v1/health", async () => ({
|
app.get("/api/v1/health", async () => ({
|
||||||
status: "healthy",
|
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 { useCallback, useState, type DragEvent } from "react";
|
||||||
import { Upload } from "lucide-react";
|
import { Upload, FileImage } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface DropzoneProps {
|
interface DropzoneProps {
|
||||||
onFiles?: (files: File[]) => void;
|
onFiles?: (files: File[]) => void;
|
||||||
accept?: string;
|
accept?: string;
|
||||||
multiple?: boolean;
|
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 [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
const handleDrag = useCallback((e: DragEvent) => {
|
const handleDrag = useCallback((e: DragEvent) => {
|
||||||
@@ -41,6 +43,8 @@ export function Dropzone({ onFiles, accept, multiple = true }: DropzoneProps) {
|
|||||||
input.click();
|
input.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasMultipleFiles = currentFiles.length > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onDragEnter={handleDrag}
|
onDragEnter={handleDrag}
|
||||||
@@ -66,6 +70,27 @@ export function Dropzone({ onFiles, accept, multiple = true }: DropzoneProps) {
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Drop files here or click the upload button
|
Drop files here or click the upload button
|
||||||
</p>
|
</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>
|
||||||
</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() {
|
export function clearToken() {
|
||||||
localStorage.removeItem("stirling-token");
|
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 { useParams } from "react-router-dom";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useCallback } from "react";
|
||||||
import { TOOLS } from "@stirling-image/shared";
|
import { TOOLS } from "@stirling-image/shared";
|
||||||
import { AppLayout } from "@/components/layout/app-layout";
|
import { AppLayout } from "@/components/layout/app-layout";
|
||||||
import { Dropzone } from "@/components/common/dropzone";
|
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";
|
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() {
|
export function ToolPage() {
|
||||||
const { toolId } = useParams<{ toolId: string }>();
|
const { toolId } = useParams<{ toolId: string }>();
|
||||||
const tool = useMemo(() => TOOLS.find((t) => t.id === toolId), [toolId]);
|
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) {
|
if (!tool) {
|
||||||
return (
|
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 (
|
return (
|
||||||
<AppLayout showToolPanel={false}>
|
<AppLayout showToolPanel={false}>
|
||||||
@@ -30,37 +79,71 @@ export function ToolPage() {
|
|||||||
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
|
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
|
||||||
<IconComponent className="h-5 w-5" />
|
<IconComponent className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="font-semibold text-lg text-foreground">{tool.name}</h2>
|
<h2 className="font-semibold text-lg text-foreground">
|
||||||
|
{tool.name}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* File info */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Files</h3>
|
<h3 className="text-sm font-medium text-muted-foreground">
|
||||||
<button className="flex items-center gap-2 text-sm text-primary hover:underline">
|
Files
|
||||||
<icons.Upload className="h-4 w-4" />
|
</h3>
|
||||||
Upload
|
{hasFile ? (
|
||||||
</button>
|
<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>
|
||||||
|
|
||||||
<div className="border-t border-border" />
|
<div className="border-t border-border" />
|
||||||
|
|
||||||
|
{/* Tool-specific settings */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Settings</h3>
|
<h3 className="text-sm font-medium text-muted-foreground">
|
||||||
<p className="text-xs text-muted-foreground italic">{tool.description}</p>
|
Settings
|
||||||
|
</h3>
|
||||||
|
<ToolSettingsPanel toolId={tool.id} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Dropzone */}
|
{/* Dropzone / Preview */}
|
||||||
<div className="flex-1 flex items-center justify-center p-6">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</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",
|
"types": "./src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
"clean": "rm -rf dist"
|
"clean": "rm -rf dist"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@stirling-image/shared": "workspace:*"
|
"@stirling-image/shared": "workspace:*",
|
||||||
|
"sharp": "^0.33.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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