diff --git a/apps/api/package.json b/apps/api/package.json index 43bf50f0..91dceaf8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,24 +11,29 @@ "clean": "rm -rf dist" }, "dependencies": { - "@stirling-image/shared": "workspace:*", - "fastify": "^5.2.0", - "@fastify/static": "^8.1.0", - "@fastify/multipart": "^9.0.0", "@fastify/cors": "^11.0.0", + "@fastify/multipart": "^9.0.0", "@fastify/rate-limit": "^10.2.0", + "@fastify/static": "^8.1.0", "@fastify/swagger": "^9.4.0", "@fastify/swagger-ui": "^5.2.0", + "@stirling-image/image-engine": "workspace:*", + "@stirling-image/shared": "workspace:*", + "archiver": "^7.0.1", + "better-sqlite3": "^11.7.0", "dotenv": "^16.4.0", - "zod": "^3.24.0", "drizzle-orm": "^0.38.0", - "better-sqlite3": "^11.7.0" + "fastify": "^5.2.0", + "p-queue": "^9.1.0", + "sharp": "^0.33.0", + "zod": "^3.24.0" }, "devDependencies": { - "typescript": "^5.7.0", - "tsx": "^4.19.0", + "@types/archiver": "^7.0.0", + "@types/better-sqlite3": "^7.6.0", "@types/node": "^22.0.0", "drizzle-kit": "^0.30.0", - "@types/better-sqlite3": "^7.6.0" + "tsx": "^4.19.0", + "typescript": "^5.7.0" } } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 524c0417..f0cbf4d8 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -7,8 +7,13 @@ import { env } from "./config.js"; import { APP_VERSION } from "@stirling-image/shared"; import { runMigrations } from "./db/migrate.js"; import { ensureDefaultAdmin, authRoutes, authMiddleware } from "./plugins/auth.js"; +import { registerUpload } from "./plugins/upload.js"; import { registerStatic } from "./plugins/static.js"; import { startCleanupCron } from "./lib/cleanup.js"; +import { fileRoutes } from "./routes/files.js"; +import { registerToolRoutes } from "./routes/tools/index.js"; +import { registerBatchRoutes } from "./routes/batch.js"; +import { registerProgressRoutes } from "./routes/progress.js"; // Run before anything else runMigrations(); @@ -45,12 +50,27 @@ await app.register(swaggerUi, { routePrefix: "/api/docs", }); +// Multipart upload support +await registerUpload(app); + // Auth middleware (must be registered before routes it protects) await authMiddleware(app); // Auth routes await authRoutes(app); +// File upload/download routes +await fileRoutes(app); + +// Tool routes (generic factory-based) +await registerToolRoutes(app); + +// Batch processing routes (must be after tool routes so the registry is populated) +await registerBatchRoutes(app); + +// Progress SSE routes +await registerProgressRoutes(app); + // Health check app.get("/api/v1/health", async () => ({ status: "healthy", diff --git a/apps/api/src/lib/file-validation.ts b/apps/api/src/lib/file-validation.ts new file mode 100644 index 00000000..90416bf2 --- /dev/null +++ b/apps/api/src/lib/file-validation.ts @@ -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 { + // 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; +} diff --git a/apps/api/src/lib/workspace.ts b/apps/api/src/lib/workspace.ts new file mode 100644 index 00000000..b8deafcd --- /dev/null +++ b/apps/api/src/lib/workspace.ts @@ -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 { + 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 { + const root = getWorkspacePath(jobId); + await rm(root, { recursive: true, force: true }); +} diff --git a/apps/api/src/plugins/upload.ts b/apps/api/src/plugins/upload.ts new file mode 100644 index 00000000..c447f08d --- /dev/null +++ b/apps/api/src/plugins/upload.ts @@ -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 { + await app.register(multipart, { + limits: { + fileSize: env.MAX_UPLOAD_SIZE_MB * 1024 * 1024, + files: env.MAX_BATCH_SIZE, + }, + }); +} diff --git a/apps/api/src/routes/batch.ts b/apps/api/src/routes/batch.ts new file mode 100644 index 00000000..79e5d60b --- /dev/null +++ b/apps/api/src/routes/batch.ts @@ -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 { + 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(); + 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(); + }, + ); +} diff --git a/apps/api/src/routes/files.ts b/apps/api/src/routes/files.ts new file mode 100644 index 00000000..9e0e8e04 --- /dev/null +++ b/apps/api/src/routes/files.ts @@ -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 { + // ── 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 = { + 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"; +} diff --git a/apps/api/src/routes/progress.ts b/apps/api/src/routes/progress.ts new file mode 100644 index 00000000..59216f07 --- /dev/null +++ b/apps/api/src/routes/progress.ts @@ -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(); + +/** SSE listeners waiting for updates, keyed by jobId. */ +const listeners = new Map 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 { + 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); + } + } + }); + }, + ); +} diff --git a/apps/api/src/routes/tool-factory.ts b/apps/api/src/routes/tool-factory.ts new file mode 100644 index 00000000..18708b84 --- /dev/null +++ b/apps/api/src/routes/tool-factory.ts @@ -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 { + /** Unique tool identifier, used as the URL path segment. */ + toolId: string; + /** Zod schema that validates the settings JSON from the request. */ + settingsSchema: z.ZodType; + /** 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>(); + +/** + * Retrieve a registered tool config by its ID. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function getToolConfig(toolId: string): ToolRouteConfig | 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( + app: FastifyInstance, + config: ToolRouteConfig, +): 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, + }); + } + }, + ); +} diff --git a/apps/api/src/routes/tools/color-adjustments.ts b/apps/api/src/routes/tools/color-adjustments.ts new file mode 100644 index 00000000..f1263530 --- /dev/null +++ b/apps/api/src/routes/tools/color-adjustments.ts @@ -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" }; + }, + }); + } +} diff --git a/apps/api/src/routes/tools/compress.ts b/apps/api/src/routes/tools/compress.ts new file mode 100644 index 00000000..3cdea283 --- /dev/null +++ b/apps/api/src/routes/tools/compress.ts @@ -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" }; + }, + }); +} diff --git a/apps/api/src/routes/tools/convert.ts b/apps/api/src/routes/tools/convert.ts new file mode 100644 index 00000000..be6bdb24 --- /dev/null +++ b/apps/api/src/routes/tools/convert.ts @@ -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 = { + 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 }; + }, + }); +} diff --git a/apps/api/src/routes/tools/crop.ts b/apps/api/src/routes/tools/crop.ts new file mode 100644 index 00000000..aafcd67c --- /dev/null +++ b/apps/api/src/routes/tools/crop.ts @@ -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" }; + }, + }); +} diff --git a/apps/api/src/routes/tools/index.ts b/apps/api/src/routes/tools/index.ts new file mode 100644 index 00000000..3635b445 --- /dev/null +++ b/apps/api/src/routes/tools/index.ts @@ -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 { + registerResize(app); + registerCrop(app); + registerRotate(app); + registerConvert(app); + registerCompress(app); + registerStripMetadata(app); + registerColorAdjustments(app); + + app.log.info("Tool routes registered (7 tools, 10 endpoints)"); +} diff --git a/apps/api/src/routes/tools/resize.ts b/apps/api/src/routes/tools/resize.ts new file mode 100644 index 00000000..4b646eda --- /dev/null +++ b/apps/api/src/routes/tools/resize.ts @@ -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" }; + }, + }); +} diff --git a/apps/api/src/routes/tools/rotate.ts b/apps/api/src/routes/tools/rotate.ts new file mode 100644 index 00000000..8cc05697 --- /dev/null +++ b/apps/api/src/routes/tools/rotate.ts @@ -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" }; + }, + }); +} diff --git a/apps/api/src/routes/tools/strip-metadata.ts b/apps/api/src/routes/tools/strip-metadata.ts new file mode 100644 index 00000000..9b83ca93 --- /dev/null +++ b/apps/api/src/routes/tools/strip-metadata.ts @@ -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" }; + }, + }); +} diff --git a/apps/web/src/components/common/before-after-slider.tsx b/apps/web/src/components/common/before-after-slider.tsx new file mode 100644 index 00000000..6aeab5be --- /dev/null +++ b/apps/web/src/components/common/before-after-slider.tsx @@ -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(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 ( +
+ {/* Slider container */} +
+ {/* Before image (full width, bottom layer) */} + Original + + {/* After image (clipped, top layer) */} + Processed + + {/* Divider line */} +
+ {/* Handle grip */} +
+ + + + +
+
+ + {/* Labels */} +
+ Original +
+
+ Processed +
+
+ + {/* Size comparison badges */} + {beforeSize != null && afterSize != null && ( +
+ + Original: {formatSize(beforeSize)} + + + Processed: {formatSize(afterSize)} + {savingsPercent !== null && Number(savingsPercent) > 0 && ( + ({savingsPercent}% smaller) + )} + {savingsPercent !== null && Number(savingsPercent) < 0 && ( + + ({Math.abs(Number(savingsPercent))}% larger) + + )} + +
+ )} +
+ ); +} diff --git a/apps/web/src/components/common/dropzone.tsx b/apps/web/src/components/common/dropzone.tsx index 61eca6cf..cd00d64d 100644 --- a/apps/web/src/components/common/dropzone.tsx +++ b/apps/web/src/components/common/dropzone.tsx @@ -1,14 +1,16 @@ import { useCallback, useState, type DragEvent } from "react"; -import { Upload } from "lucide-react"; +import { Upload, FileImage } from "lucide-react"; import { cn } from "@/lib/utils"; interface DropzoneProps { onFiles?: (files: File[]) => void; accept?: string; multiple?: boolean; + /** Files that have already been dropped (for showing count & list). */ + currentFiles?: File[]; } -export function Dropzone({ onFiles, accept, multiple = true }: DropzoneProps) { +export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }: DropzoneProps) { const [isDragging, setIsDragging] = useState(false); const handleDrag = useCallback((e: DragEvent) => { @@ -41,6 +43,8 @@ export function Dropzone({ onFiles, accept, multiple = true }: DropzoneProps) { input.click(); }; + const hasMultipleFiles = currentFiles.length > 1; + return (
Drop files here or click the upload button

+ + {/* Show file count badge and list when multiple files are dropped */} + {hasMultipleFiles && ( +
+ + + {currentFiles.length} files selected + +
+ {currentFiles.map((f, i) => ( +
+ {f.name} + {(f.size / 1024).toFixed(0)} KB +
+ ))} +
+
+ )}
); diff --git a/apps/web/src/components/tools/color-settings.tsx b/apps/web/src/components/tools/color-settings.tsx new file mode 100644 index 00000000..7dde67a0 --- /dev/null +++ b/apps/web/src/components/tools/color-settings.tsx @@ -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(() => { + 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("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 ( +
+ {/* Tabs */} +
+ {tabs.map((t) => ( + + ))} +
+ + {/* Basic Adjustments */} + {tab === "basic" && ( +
+ + + +
+ )} + + {/* Color Channels */} + {tab === "channels" && ( +
+ + + +
+ )} + + {/* Effects */} + {tab === "effects" && ( +
+ +
+ {(["none", "grayscale", "sepia", "invert"] as const).map((e) => ( + + ))} +
+
+ )} + + {/* Reset button */} + {hasChanges && ( + + )} + + {/* Error */} + {error &&

{error}

} + + {/* Size info */} + {originalSize != null && processedSize != null && ( +
+

Original: {(originalSize / 1024).toFixed(1)} KB

+

Processed: {(processedSize / 1024).toFixed(1)} KB

+
+ )} + + {/* Process */} + + + {/* Download */} + {downloadUrl && ( + + + Download + + )} +
+ ); +} + +/** 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 ( +
+
+ + {value} +
+ onChange(Number(e.target.value))} + className="w-full mt-0.5" + /> +
+ ); +} diff --git a/apps/web/src/components/tools/compress-settings.tsx b/apps/web/src/components/tools/compress-settings.tsx new file mode 100644 index 00000000..c753f003 --- /dev/null +++ b/apps/web/src/components/tools/compress-settings.tsx @@ -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("quality"); + const [quality, setQuality] = useState(75); + const [targetSizeKb, setTargetSizeKb] = useState(""); + + const handleProcess = () => { + const settings: Record = { 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 ( +
+ {/* Mode toggle */} +
+ +
+ + +
+
+ + {mode === "quality" ? ( +
+
+ + {quality} +
+ setQuality(Number(e.target.value))} + className="w-full mt-1" + /> +
+ Smallest file + Best quality +
+
+ ) : ( +
+ + 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" + /> +
+ )} + + {/* Error */} + {error &&

{error}

} + + {/* Size info */} + {originalSize != null && processedSize != null && ( +
+

Original: {(originalSize / 1024).toFixed(1)} KB

+

Processed: {(processedSize / 1024).toFixed(1)} KB

+

+ Saved:{" "} + {originalSize > 0 + ? ((1 - processedSize / originalSize) * 100).toFixed(1) + : "0"} + % +

+
+ )} + + {/* Process */} + + + {/* Download */} + {downloadUrl && ( + + + Download + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/convert-settings.tsx b/apps/web/src/components/tools/convert-settings.tsx new file mode 100644 index 00000000..a95e37f3 --- /dev/null +++ b/apps/web/src/components/tools/convert-settings.tsx @@ -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("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 = { format }; + if (isLossy) { + settings.quality = quality; + } + processFiles(files, settings); + }; + + const hasFile = files.length > 0; + + return ( +
+ {/* Source format */} + {hasFile && ( +
+ +
+ {sourceExt} +
+
+ )} + + {/* Target format */} +
+ + +
+ + {/* Quality slider (lossy only) */} + {isLossy && ( +
+
+ + {quality} +
+ setQuality(Number(e.target.value))} + className="w-full mt-1" + /> +
+ )} + + {/* Error */} + {error &&

{error}

} + + {/* Size info */} + {originalSize != null && processedSize != null && ( +
+

Original: {(originalSize / 1024).toFixed(1)} KB

+

Processed: {(processedSize / 1024).toFixed(1)} KB

+

+ Savings:{" "} + {originalSize > 0 + ? ((1 - processedSize / originalSize) * 100).toFixed(1) + : "0"} + % +

+
+ )} + + {/* Process */} + + + {/* Download */} + {downloadUrl && ( + + + Download + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/crop-settings.tsx b/apps/web/src/components/tools/crop-settings.tsx new file mode 100644 index 00000000..fbd8d5af --- /dev/null +++ b/apps/web/src/components/tools/crop-settings.tsx @@ -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 ( +
+ {/* Position */} +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + {/* Size */} +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + {/* Aspect ratio presets */} +
+ +
+ {ASPECT_PRESETS.map(({ label, w, h }) => ( + + ))} +
+
+ + {/* Error */} + {error &&

{error}

} + + {/* Size info */} + {originalSize != null && processedSize != null && ( +
+

Original: {(originalSize / 1024).toFixed(1)} KB

+

Processed: {(processedSize / 1024).toFixed(1)} KB

+
+ )} + + {/* Process */} + + + {/* Download */} + {downloadUrl && ( + + + Download + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/resize-settings.tsx b/apps/web/src/components/tools/resize-settings.tsx new file mode 100644 index 00000000..bc143020 --- /dev/null +++ b/apps/web/src/components/tools/resize-settings.tsx @@ -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(""); + const [height, setHeight] = useState(""); + const [percentage, setPercentage] = useState("100"); + const [fit, setFit] = useState("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 = { 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 ( +
+ {/* Mode toggle */} +
+ +
+ + +
+
+ + {mode === "pixels" ? ( + <> + {/* Width / Height */} +
+
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+
+
+ + {/* Fit mode */} +
+ + +
+ + {/* Social media presets */} +
+ + +
+ + ) : ( +
+ + 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" + /> +
+ )} + + {/* Don't enlarge */} + + + {/* Error */} + {error && ( +

{error}

+ )} + + {/* Size info */} + {originalSize != null && processedSize != null && ( +
+

Original: {(originalSize / 1024).toFixed(1)} KB

+

Processed: {(processedSize / 1024).toFixed(1)} KB

+
+ )} + + {/* Process button */} + + + {/* Download */} + {downloadUrl && ( + + + Download + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/rotate-settings.tsx b/apps/web/src/components/tools/rotate-settings.tsx new file mode 100644 index 00000000..9ef0489d --- /dev/null +++ b/apps/web/src/components/tools/rotate-settings.tsx @@ -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 ( +
+ {/* Quick rotate buttons */} +
+ +
+ + +
+
+ + {/* Angle slider */} +
+
+ + {angle} deg +
+ setAngle(Number(e.target.value))} + className="w-full mt-1" + /> +
+ + {/* Flip buttons */} +
+ +
+ + +
+
+ + {/* Error */} + {error &&

{error}

} + + {/* Size info */} + {originalSize != null && processedSize != null && ( +
+

Original: {(originalSize / 1024).toFixed(1)} KB

+

Processed: {(processedSize / 1024).toFixed(1)} KB

+
+ )} + + {/* Process */} + + + {/* Download */} + {downloadUrl && ( + + + Download + + )} +
+ ); +} diff --git a/apps/web/src/components/tools/strip-metadata-settings.tsx b/apps/web/src/components/tools/strip-metadata-settings.tsx new file mode 100644 index 00000000..94a2c60a --- /dev/null +++ b/apps/web/src/components/tools/strip-metadata-settings.tsx @@ -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 ( +
+ {/* Strip All */} + + +
+ + {/* Individual options */} +
+ + + + + + + + + +
+ + {/* Error */} + {error &&

{error}

} + + {/* Size info */} + {originalSize != null && processedSize != null && ( +
+

Original: {(originalSize / 1024).toFixed(1)} KB

+

Processed: {(processedSize / 1024).toFixed(1)} KB

+

Metadata removed: {((originalSize - processedSize) / 1024).toFixed(1)} KB

+
+ )} + + {/* Process */} + + + {/* Download */} + {downloadUrl && ( + + + Download + + )} +
+ ); +} diff --git a/apps/web/src/hooks/use-batch-processor.ts b/apps/web/src/hooks/use-batch-processor.ts new file mode 100644 index 00000000..9623fa4f --- /dev/null +++ b/apps/web/src/hooks/use-batch-processor.ts @@ -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({ + totalFiles: 0, + completedFiles: 0, + failedFiles: 0, + errors: [], + status: "idle", + percent: 0, + }); + + const abortRef = useRef(null); + + const processBatch = useCallback( + async (files: File[], settings: Record) => { + 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, + }; +} diff --git a/apps/web/src/hooks/use-tool-processor.ts b/apps/web/src/hooks/use-tool-processor.ts new file mode 100644 index 00000000..058ce902 --- /dev/null +++ b/apps/web/src/hooks/use-tool-processor.ts @@ -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) => { + 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, + }; +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 1fb412d8..dbe4afe3 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -32,3 +32,37 @@ export function setToken(token: string) { export function clearToken() { localStorage.removeItem("stirling-token"); } + +// ── File Upload / Download ────────────────────────────────────── + +export async function apiUpload( + files: File[], +): Promise<{ + jobId: string; + files: Array<{ name: string; size: number; format: string }>; +}> { + const formData = new FormData(); + files.forEach((f) => formData.append("files", f)); + const res = await fetch("/api/v1/upload", { + method: "POST", + headers: { Authorization: `Bearer ${getToken()}` }, + body: formData, + }); + if (!res.ok) throw new Error(`Upload failed: ${res.status}`); + return res.json(); +} + +export function getDownloadUrl(jobId: string, filename: string): string { + return `/api/v1/download/${jobId}/${filename}`; +} + +export async function apiDownloadBlob( + jobId: string, + filename: string, +): Promise { + const res = await fetch(getDownloadUrl(jobId, filename), { + headers: { Authorization: `Bearer ${getToken()}` }, + }); + if (!res.ok) throw new Error(`Download failed: ${res.status}`); + return res.blob(); +} diff --git a/apps/web/src/pages/tool-page.tsx b/apps/web/src/pages/tool-page.tsx index c4a6bf14..762f4001 100644 --- a/apps/web/src/pages/tool-page.tsx +++ b/apps/web/src/pages/tool-page.tsx @@ -1,13 +1,54 @@ import { useParams } from "react-router-dom"; -import { useMemo } from "react"; +import { useMemo, useCallback } from "react"; import { TOOLS } from "@stirling-image/shared"; import { AppLayout } from "@/components/layout/app-layout"; import { Dropzone } from "@/components/common/dropzone"; +import { BeforeAfterSlider } from "@/components/common/before-after-slider"; +import { useFileStore } from "@/stores/file-store"; +import { ResizeSettings } from "@/components/tools/resize-settings"; +import { CropSettings } from "@/components/tools/crop-settings"; +import { RotateSettings } from "@/components/tools/rotate-settings"; +import { ConvertSettings } from "@/components/tools/convert-settings"; +import { CompressSettings } from "@/components/tools/compress-settings"; +import { StripMetadataSettings } from "@/components/tools/strip-metadata-settings"; +import { ColorSettings } from "@/components/tools/color-settings"; import * as icons from "lucide-react"; +const COLOR_TOOL_IDS = new Set([ + "brightness-contrast", + "saturation", + "color-channels", + "color-effects", +]); + +function ToolSettingsPanel({ toolId }: { toolId: string }) { + if (toolId === "resize") return ; + if (toolId === "crop") return ; + if (toolId === "rotate") return ; + if (toolId === "convert") return ; + if (toolId === "compress") return ; + if (toolId === "strip-metadata") return ; + if (COLOR_TOOL_IDS.has(toolId)) return ; + + return ( +

+ Settings for this tool are coming soon. +

+ ); +} + export function ToolPage() { const { toolId } = useParams<{ toolId: string }>(); const tool = useMemo(() => TOOLS.find((t) => t.id === toolId), [toolId]); + const { files, setFiles, reset, processedUrl, originalBlobUrl, originalSize, processedSize } = useFileStore(); + + const handleFiles = useCallback( + (newFiles: File[]) => { + reset(); + setFiles(newFiles); + }, + [setFiles, reset], + ); if (!tool) { return ( @@ -19,7 +60,15 @@ export function ToolPage() { ); } - const IconComponent = (icons as unknown as Record>)[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 ( @@ -30,37 +79,71 @@ export function ToolPage() {
-

{tool.name}

+

+ {tool.name} +

+ {/* File info */}
-

Files

- +

+ Files +

+ {hasFile ? ( +
+ {files.map((f, i) => ( +
+ {f.name} + + {(f.size / 1024).toFixed(0)} KB + +
+ ))} + +
+ ) : ( +

+ Drop or upload an image to get started +

+ )}
+ {/* Tool-specific settings */}
-

Settings

-

{tool.description}

+

+ Settings +

+
- -
- -
- {/* Dropzone */} + {/* Dropzone / Preview */}
- + {processedUrl && originalBlobUrl ? ( + + ) : ( + + )}
diff --git a/apps/web/src/stores/file-store.ts b/apps/web/src/stores/file-store.ts new file mode 100644 index 00000000..90bec383 --- /dev/null +++ b/apps/web/src/stores/file-store.ts @@ -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((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, + }); + }, +})); diff --git a/docs/superpowers/plans/2026-03-22-phase2-core-tools.md b/docs/superpowers/plans/2026-03-22-phase2-core-tools.md new file mode 100644 index 00000000..39868104 --- /dev/null +++ b/docs/superpowers/plans/2026-03-22-phase2-core-tools.md @@ -0,0 +1,1096 @@ +# Phase 2: Core Tools Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the image processing engine, file upload/download pipeline, and the first 10 core tools with both API endpoints and frontend settings UI. This is the phase where Stirling-Image goes from a shell to a functional image processing suite. + +**Architecture:** Each tool follows a uniform pattern: Fastify route accepts multipart file upload + JSON settings, delegates to the `@stirling-image/image-engine` package (Sharp wrapper), returns the processed file for download. A generic route factory eliminates boilerplate across all 37 tools. Batch processing uses p-queue for concurrency control and SSE for progress. + +**Tech Stack:** Sharp (libvips), @fastify/multipart, p-queue, archiver (ZIP), react-image-crop, Zustand, SSE + +**Spec:** `PRD.md` sections 5.1, 5.2, 5.3, 15 + +**Depends on:** Phase 1 (foundation) -- completed + +--- + +## Task 1: Image Engine Package + +Build the Sharp wrapper in `packages/image-engine`. Each operation is a separate file exporting a single async function. All operations accept a Sharp instance (or Buffer) and return a Sharp instance (or Buffer), making them composable for the pipeline builder later. + +### Files to create + +``` +packages/image-engine/ +├── src/ +│ ├── index.ts # Re-exports all operations + types +│ ├── types.ts # Shared types: OperationResult, ImageInfo, format enums +│ ├── engine.ts # Core engine: load image, detect format, apply operations, output +│ ├── operations/ +│ │ ├── resize.ts # Resize by pixels, percentage, fit mode +│ │ ├── crop.ts # Crop by coordinates (left, top, width, height) +│ │ ├── rotate.ts # Rotate by angle, auto-crop background +│ │ ├── flip.ts # Flip horizontal / vertical +│ │ ├── convert.ts # Convert between formats with format-specific options +│ │ ├── compress.ts # Quality-based and target-size compression +│ │ ├── strip-metadata.ts # Selective metadata removal (EXIF, GPS, ICC, XMP) +│ │ ├── brightness.ts # Brightness adjustment via Sharp modulate/linear +│ │ ├── contrast.ts # Contrast adjustment via linear transform +│ │ ├── saturation.ts # Saturation adjustment via Sharp modulate +│ │ ├── color-channels.ts # Per-channel R/G/B multipliers via recomb +│ │ ├── grayscale.ts # Convert to grayscale +│ │ ├── sepia.ts # Sepia tone via recomb matrix +│ │ └── invert.ts # Invert colors via Sharp negate +│ ├── formats/ +│ │ └── detect.ts # Format detection from buffer magic bytes + MIME mapping +│ └── utils/ +│ ├── metadata.ts # Read/parse EXIF, GPS, camera info via Sharp metadata() +│ └── mime.ts # Extension <-> MIME type mapping +``` + +### Files to modify + +``` +packages/image-engine/package.json # Add sharp dependency +``` + +### Key interfaces + +```typescript +// types.ts +export interface ImageInfo { + width: number; + height: number; + format: string; + channels: number; + size: number; + hasAlpha: boolean; + metadata: Record; +} + +export interface OperationResult { + buffer: Buffer; + info: ImageInfo; +} + +// engine.ts +export async function processImage( + input: Buffer, + operations: ImageOperation[], + outputFormat?: OutputFormat +): Promise; + +export async function getImageInfo(input: Buffer): Promise; + +// operations/resize.ts +export interface ResizeOptions { + width?: number; + height?: number; + fit?: 'contain' | 'cover' | 'fill' | 'inside' | 'outside'; + withoutEnlargement?: boolean; + percentage?: number; +} +export async function resize(image: Sharp, options: ResizeOptions): Promise; + +// operations/crop.ts +export interface CropOptions { + left: number; + top: number; + width: number; + height: number; +} +export async function crop(image: Sharp, options: CropOptions): Promise; + +// operations/compress.ts +export interface CompressOptions { + quality?: number; // 1-100 + targetSizeBytes?: number; // binary search to hit target + format?: OutputFormat; +} +export async function compress(image: Sharp, options: CompressOptions): Promise; +``` + +### Steps + +- [ ] Add `sharp` as a dependency in `packages/image-engine/package.json` +- [ ] Create `src/types.ts` with `ImageInfo`, `OperationResult`, `ResizeOptions`, `CropOptions`, `RotateOptions`, `FlipOptions`, `ConvertOptions`, `CompressOptions`, `StripMetadataOptions`, `BrightnessOptions`, `ContrastOptions`, `SaturationOptions`, `ColorChannelOptions`, `OutputFormat` type +- [ ] Create `src/formats/detect.ts` -- use Sharp metadata + magic-byte fallback to detect input format, export `detectFormat(buffer: Buffer): Promise` +- [ ] Create `src/utils/mime.ts` -- bidirectional map between file extensions and MIME types for all supported formats +- [ ] Create `src/utils/metadata.ts` -- wrap `sharp(buffer).metadata()` and parse EXIF fields into structured object +- [ ] Create `src/operations/resize.ts` -- use `sharp.resize()` with fit mode mapping +- [ ] Create `src/operations/crop.ts` -- use `sharp.extract()` with bounds validation +- [ ] Create `src/operations/rotate.ts` -- use `sharp.rotate(angle)` with background option for non-90 angles +- [ ] Create `src/operations/flip.ts` -- use `sharp.flip()` and `sharp.flop()` +- [ ] Create `src/operations/convert.ts` -- use `sharp.toFormat()` with per-format quality/option defaults from PRD section 6.2 +- [ ] Create `src/operations/compress.ts` -- quality mode: pass quality to format encoder; target-size mode: binary search (max 8 iterations) adjusting quality until output is within 5% of target +- [ ] Create `src/operations/strip-metadata.ts` -- use `sharp.withMetadata()` / `sharp.keepMetadata()` with selective field control +- [ ] Create `src/operations/brightness.ts` -- use `sharp.modulate({ brightness })` where 1.0 = no change, map -100..+100 slider to 0..2 multiplier +- [ ] Create `src/operations/contrast.ts` -- use `sharp.linear(a, b)` where a is contrast multiplier, map -100..+100 to 0.5..1.5 +- [ ] Create `src/operations/saturation.ts` -- use `sharp.modulate({ saturation })` where 1.0 = no change +- [ ] Create `src/operations/color-channels.ts` -- use `sharp.recomb()` with 3x3 matrix for per-channel multipliers +- [ ] Create `src/operations/grayscale.ts` -- use `sharp.grayscale()` +- [ ] Create `src/operations/sepia.ts` -- use `sharp.recomb()` with sepia matrix `[[0.393,0.769,0.189],[0.349,0.686,0.168],[0.272,0.534,0.131]]` +- [ ] Create `src/operations/invert.ts` -- use `sharp.negate()` +- [ ] Create `src/engine.ts` -- the orchestrator that loads a buffer, chains operations, and outputs in the requested format +- [ ] Update `src/index.ts` to re-export everything +- [ ] Write unit tests: `packages/image-engine/tests/operations.test.ts` -- test each operation with a small test image (1x1 or 10x10 PNG generated in-memory via Sharp). Verify output dimensions, format, and that no errors are thrown. + +### Test + +```bash +cd packages/image-engine && pnpm test +``` + +Create a test that generates a 100x100 red PNG in-memory, runs each operation, and asserts the output is a valid image buffer with expected properties. + +### Commit + +``` +feat(image-engine): add Sharp wrapper with 14 image operations + +Operations: resize, crop, rotate, flip, convert, compress, strip-metadata, +brightness, contrast, saturation, color-channels, grayscale, sepia, invert. +Includes format detection, MIME mapping, and metadata parsing. +``` + +--- + +## Task 2: File Upload & Download System + +Add multipart file upload to Fastify, workspace session management (temp directory per processing request), and download routes. This is the backbone all tools share. + +### Files to create + +``` +apps/api/src/plugins/upload.ts # Register @fastify/multipart with size limits +apps/api/src/lib/workspace.ts # Create/manage temp dirs per job: create(jobId), getPath(jobId, filename), cleanup(jobId) +apps/api/src/routes/files.ts # POST /api/v1/upload, GET /api/v1/download/:jobId/:filename +apps/api/src/lib/file-validation.ts # Validate file type (magic bytes, not just extension), size, megapixel limit +``` + +### Files to modify + +``` +apps/api/src/index.ts # Register upload plugin + file routes +apps/api/src/lib/env.ts # Already has WORKSPACE_PATH, MAX_UPLOAD_SIZE_MB -- no changes needed +apps/web/src/lib/api.ts # Add apiUpload() for multipart form data, apiDownload() for blob download +``` + +### Key interfaces + +```typescript +// plugins/upload.ts +export async function registerUpload(app: FastifyInstance): Promise; +// Registers @fastify/multipart with limits: fileSize from env.MAX_UPLOAD_SIZE_MB + +// lib/workspace.ts +export function createWorkspace(jobId: string): string; // returns absolute path to temp dir +export function getWorkspacePath(jobId: string): string; +export function cleanupWorkspace(jobId: string): Promise; + +// lib/file-validation.ts +export interface ValidationResult { valid: boolean; error?: string; detectedFormat: string; } +export async function validateImageFile(buffer: Buffer, filename: string): Promise; +// Checks: buffer not empty, magic bytes match image format, format in SUPPORTED_INPUT_FORMATS, +// dimensions within MAX_MEGAPIXELS + +// routes/files.ts +// POST /api/v1/upload -- accepts multipart/form-data, saves to workspace, returns { jobId, files: [{ name, size, format }] } +// GET /api/v1/download/:jobId/:filename -- serves file from workspace with Content-Disposition: attachment + +// web lib/api.ts additions +export async function apiUpload(file: File): Promise; +export async function apiDownloadBlob(jobId: string, filename: string): Promise; +``` + +### Steps + +- [ ] Create `apps/api/src/plugins/upload.ts` -- register `@fastify/multipart` with `limits: { fileSize: env.MAX_UPLOAD_SIZE_MB * 1024 * 1024 }` and `attachFieldsToBody: false` (use streaming/pump approach) +- [ ] Create `apps/api/src/lib/workspace.ts` -- `createWorkspace` creates `${WORKSPACE_PATH}/${jobId}/input/` and `${WORKSPACE_PATH}/${jobId}/output/` directories, returns the job workspace root +- [ ] Create `apps/api/src/lib/file-validation.ts` -- validate magic bytes using first 12 bytes of buffer against known signatures (JPEG: `FF D8 FF`, PNG: `89 50 4E 47`, WebP: `52 49 46 46...57 45 42 50`, etc.), check format against `SUPPORTED_INPUT_FORMATS`, check dimensions via `sharp(buffer).metadata()` against `MAX_MEGAPIXELS` +- [ ] Create `apps/api/src/routes/files.ts` -- upload route: generate jobId via `randomUUID()`, create workspace, iterate multipart parts, validate each file, save to `input/` directory, return job metadata. Download route: resolve path within workspace, validate it exists, stream file with proper Content-Type and Content-Disposition headers. Guard against path traversal (reject `..` in filenames) +- [ ] Modify `apps/api/src/index.ts` -- import and register upload plugin and file routes +- [ ] Add `apiUpload` to `apps/web/src/lib/api.ts` -- construct FormData, POST to `/api/v1/upload`, return parsed JSON response +- [ ] Add `apiDownloadBlob` to `apps/web/src/lib/api.ts` -- fetch blob from download route, return blob for client-side download trigger + +### Test + +```bash +# Manual: use curl to upload a test image +curl -X POST http://localhost:1349/api/v1/upload \ + -H "Authorization: Bearer " \ + -F "file=@test.jpg" + +# Verify response contains jobId and file metadata +# Then download: +curl http://localhost:1349/api/v1/download//test.jpg \ + -H "Authorization: Bearer " -o output.jpg +``` + +### Commit + +``` +feat(api): add multipart file upload, workspace management, and download routes + +POST /api/v1/upload accepts images with magic-byte validation. +GET /api/v1/download/:jobId/:filename serves processed results. +Workspace creates isolated temp dirs per job with auto-cleanup. +``` + +--- + +## Task 3: Generic Tool Route Factory + +Create a reusable pattern for tool API routes. Every tool follows the same flow: accept file upload + JSON settings body, process via image-engine, return processed file. The factory eliminates duplicating this boilerplate for each of the 37 tools. + +### Files to create + +``` +apps/api/src/routes/tool-factory.ts # Generic route factory +apps/api/src/routes/tools/index.ts # Registers all tool routes +``` + +### Files to modify + +``` +apps/api/src/index.ts # Import and register tool routes +packages/shared/src/types.ts # Add ToolSettings base type, ProcessResponse type +``` + +### Key interfaces + +```typescript +// routes/tool-factory.ts +export interface ToolRouteConfig { + toolId: string; // matches TOOLS[].id from shared constants + settingsSchema: ZodType; // Zod schema for validating settings JSON + process: (input: Buffer, settings: TSettings, info: ImageInfo) => Promise; + acceptsMultiple?: boolean; // default false; true for batch tools +} + +export function createToolRoute( + app: FastifyInstance, + config: ToolRouteConfig +): void; +// Registers: POST /api/v1/tools/:toolId +// Flow: +// 1. Parse multipart: extract file(s) + "settings" JSON field +// 2. Validate settings against config.settingsSchema +// 3. Create workspace (jobId) +// 4. Save input file to workspace +// 5. Call config.process(buffer, validatedSettings, imageInfo) +// 6. Save output to workspace +// 7. Return { jobId, output: { filename, size, format, downloadUrl } } + +// routes/tools/index.ts +export async function registerToolRoutes(app: FastifyInstance): Promise; +// Loops through tool configs and calls createToolRoute for each + +// shared/types.ts additions +export interface ProcessResponse { + jobId: string; + output: { + filename: string; + size: number; + format: string; + width: number; + height: number; + downloadUrl: string; + }; + originalSize: number; + processingTimeMs: number; +} +``` + +### Steps + +- [ ] Add `ProcessResponse` type to `packages/shared/src/types.ts` +- [ ] Create `apps/api/src/routes/tool-factory.ts` -- implement `createToolRoute` that handles the full upload-process-download cycle. Use `performance.now()` to measure processing time. Catch errors from the process function and return structured error responses with the original filename +- [ ] Create `apps/api/src/routes/tools/index.ts` -- placeholder that will import and register each tool as they are built in tasks 4-10 +- [ ] Modify `apps/api/src/index.ts` -- register tool routes via `registerToolRoutes(app)` after auth middleware + +### Test + +```bash +# Will be fully testable after Task 4 (Resize) adds the first tool +# For now: verify the factory compiles and the /api/v1/tools route prefix is registered +pnpm --filter @stirling-image/api typecheck +``` + +### Commit + +``` +feat(api): add generic tool route factory for uniform tool endpoints + +createToolRoute() handles multipart upload, settings validation, image +processing delegation, and download URL generation. Eliminates per-tool +boilerplate for all 37 tools. +``` + +--- + +## Task 4: Resize Tool + +First tool built on the factory. API route + full frontend settings panel. + +### Files to create + +``` +apps/api/src/routes/tools/resize.ts # Tool config using createToolRoute +apps/web/src/components/tools/resize-settings.tsx # Width/height inputs, aspect ratio lock, presets, fit mode +apps/web/src/stores/file-store.ts # Zustand store for uploaded files + processing state +apps/web/src/hooks/use-tool-processor.ts # Hook: upload file, send settings, poll/wait, trigger download +``` + +### Files to modify + +``` +apps/api/src/routes/tools/index.ts # Register resize route +apps/web/src/pages/tool-page.tsx # Render tool-specific settings component based on toolId +``` + +### Key interfaces + +```typescript +// routes/tools/resize.ts +const resizeSettingsSchema = z.object({ + width: z.number().int().min(1).max(16384).optional(), + height: z.number().int().min(1).max(16384).optional(), + percentage: z.number().min(1).max(1000).optional(), + fit: z.enum(['contain', 'cover', 'fill', 'inside', 'outside']).default('contain'), + withoutEnlargement: z.boolean().default(true), + outputFormat: z.enum(['jpg', 'png', 'webp', 'avif', 'tiff']).optional(), +}); + +// components/tools/resize-settings.tsx +interface ResizeSettingsProps { + settings: ResizeSettings; + onChange: (settings: ResizeSettings) => void; + imageInfo?: ImageInfo; // original image dimensions for aspect ratio calc +} + +// stores/file-store.ts (Zustand) +interface FileStore { + files: UploadedFile[]; + activeFile: UploadedFile | null; + processing: boolean; + result: ProcessResponse | null; + addFiles: (files: File[]) => void; + removeFile: (id: string) => void; + setResult: (result: ProcessResponse) => void; + reset: () => void; +} + +// hooks/use-tool-processor.ts +function useToolProcessor(toolId: string): { + process: (file: File, settings: Record) => Promise; + download: (jobId: string, filename: string) => Promise; + processing: boolean; + progress: number; + result: ProcessResponse | null; + error: string | null; +}; +``` + +### Steps + +- [ ] Create `apps/web/src/stores/file-store.ts` -- Zustand store tracking uploaded files (id, name, size, objectUrl for preview, file reference), active file selection, processing state, and result +- [ ] Create `apps/web/src/hooks/use-tool-processor.ts` -- hook that wraps apiUpload + tool processing call + download trigger. Constructs FormData with file + settings JSON, POSTs to `/api/v1/tools/:toolId`, manages loading/error states +- [ ] Create `apps/api/src/routes/tools/resize.ts` -- define `resizeSettingsSchema`, call `resize()` from image-engine, register via `createToolRoute` +- [ ] Register resize in `apps/api/src/routes/tools/index.ts` +- [ ] Create `apps/web/src/components/tools/resize-settings.tsx` -- width/height number inputs (linked by aspect ratio lock toggle), social media presets dropdown (from `SOCIAL_MEDIA_PRESETS` in shared constants), fit mode selector (radio group: contain/cover/fill), percentage input as alternative mode +- [ ] Modify `apps/web/src/pages/tool-page.tsx` -- import a `toolSettingsMap` keyed by `toolId`, render the matching settings component in the sidebar. Wire `Dropzone.onFiles` to `fileStore.addFiles`. Wire Process button to `useToolProcessor.process`. Show download button when result is available + +### Test + +```bash +# Start dev servers +pnpm dev + +# 1. Navigate to /resize +# 2. Upload a test image +# 3. Set width=500, height=500, fit=cover +# 4. Click Process +# 5. Verify download produces a 500x500 image +# 6. Test aspect ratio lock: enter width, verify height auto-calculates +# 7. Test social media presets: select "Instagram Post", verify 1080x1080 +``` + +### Commit + +``` +feat: add resize tool with API endpoint and frontend settings UI + +Includes social media presets, aspect ratio lock, fit mode selector. +Also adds file-store (Zustand), use-tool-processor hook, and tool-page +settings rendering pattern used by all subsequent tools. +``` + +--- + +## Task 5: Crop Tool + +Interactive visual crop on the uploaded image preview. + +### Files to create + +``` +apps/api/src/routes/tools/crop.ts # Tool config +apps/web/src/components/tools/crop-settings.tsx # Aspect ratio presets, dimension inputs +apps/web/src/components/common/image-cropper.tsx # Interactive crop component wrapping react-image-crop +``` + +### Files to modify + +``` +apps/api/src/routes/tools/index.ts # Register crop route +apps/web/src/pages/tool-page.tsx # Add crop to toolSettingsMap +apps/web/package.json # Add react-image-crop dependency +``` + +### Key interfaces + +```typescript +// routes/tools/crop.ts +const cropSettingsSchema = z.object({ + left: z.number().min(0), + top: z.number().min(0), + width: z.number().min(1), + height: z.number().min(1), +}); + +// components/common/image-cropper.tsx +interface ImageCropperProps { + src: string; // object URL of uploaded image + aspectRatio?: number; // locked aspect ratio (e.g., 1 for 1:1, 16/9) + onCropChange: (crop: CropArea) => void; +} +// Uses react-image-crop to render a draggable/resizable crop box over the image. +// Outputs pixel coordinates (left, top, width, height) relative to original image dimensions. + +// components/tools/crop-settings.tsx +// Aspect ratio preset buttons: Free, 1:1, 4:3, 16:9, 2:3, 4:5, 9:16 +// Manual dimension inputs for left, top, width, height (updates crop box) +// Displays current crop dimensions +``` + +### Steps + +- [ ] Add `react-image-crop` to `apps/web/package.json` +- [ ] Create `apps/web/src/components/common/image-cropper.tsx` -- wrap `ReactCrop` component, handle percentage-to-pixel coordinate conversion based on actual image dimensions vs rendered dimensions, emit `CropArea` with absolute pixel values +- [ ] Create `apps/api/src/routes/tools/crop.ts` -- validate coordinates are within image bounds (clamp if needed), call `crop()` from image-engine +- [ ] Register crop in `apps/api/src/routes/tools/index.ts` +- [ ] Create `apps/web/src/components/tools/crop-settings.tsx` -- aspect ratio preset buttons that lock the `ReactCrop` aspect, manual coordinate inputs that sync bidirectionally with the crop box +- [ ] Modify `apps/web/src/pages/tool-page.tsx` -- for crop tool, render `ImageCropper` in the main area instead of static preview. Add crop to `toolSettingsMap` + +### Test + +```bash +# 1. Navigate to /crop +# 2. Upload a 1920x1080 image +# 3. Draw a crop area, verify coordinates display +# 4. Select 1:1 aspect ratio, verify crop box constrains +# 5. Click Process, verify output dimensions match crop area +# 6. Test edge case: crop area exceeds image bounds +``` + +### Commit + +``` +feat: add crop tool with interactive visual crop area and aspect presets + +Uses react-image-crop for drag-to-select crop region. Supports aspect +ratio presets (1:1, 4:3, 16:9, etc.) and manual coordinate input. +``` + +--- + +## Task 6: Rotate & Flip Tool + +### Files to create + +``` +apps/api/src/routes/tools/rotate.ts # Tool config +apps/web/src/components/tools/rotate-settings.tsx # Rotation controls + flip buttons +``` + +### Files to modify + +``` +apps/api/src/routes/tools/index.ts # Register rotate route +apps/web/src/pages/tool-page.tsx # Add rotate to toolSettingsMap +``` + +### Key interfaces + +```typescript +// routes/tools/rotate.ts +const rotateSettingsSchema = z.object({ + angle: z.number().min(0).max(360).default(0), + flipHorizontal: z.boolean().default(false), + flipVertical: z.boolean().default(false), + backgroundColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).default('#000000'), // fill for non-90 angles +}); + +// components/tools/rotate-settings.tsx +// - 90-degree quick buttons: [Rotate Left] [Rotate Right] (decrement/increment by 90) +// - Arbitrary angle input: number input or slider (0-360) +// - Flip buttons: [Flip Horizontal] [Flip Vertical] (toggles) +// - Background color picker (for non-90-degree rotation fill) +// - Live rotation preview via CSS transform on the image thumbnail +``` + +### Steps + +- [ ] Create `apps/api/src/routes/tools/rotate.ts` -- apply rotation first (via `rotate()` from image-engine), then flip if requested (via `flip()` from image-engine). For non-90-degree angles, use `backgroundColor` option +- [ ] Register rotate in `apps/api/src/routes/tools/index.ts` +- [ ] Create `apps/web/src/components/tools/rotate-settings.tsx` -- four quick-rotate buttons (-90, +90, 180, 0/reset), arbitrary angle slider (0-360 range), flip H/V toggle buttons with icons, background color picker (shown only when angle is not a multiple of 90). Apply `CSS transform: rotate(Xdeg) scaleX(flip)` on the image element for instant visual feedback before server processing +- [ ] Add rotate to `toolSettingsMap` in `apps/web/src/pages/tool-page.tsx` + +### Test + +```bash +# 1. Navigate to /rotate +# 2. Upload an image +# 3. Click Rotate Right, verify preview rotates 90 degrees CW +# 4. Click Flip Horizontal, verify preview mirrors +# 5. Set angle to 45, verify preview shows angled image +# 6. Process and download, verify output matches preview +# 7. Verify non-90-degree rotation fills background with selected color +``` + +### Commit + +``` +feat: add rotate & flip tool with live CSS preview and arbitrary angle support + +Quick 90-degree buttons, arbitrary angle slider, flip H/V toggles. +CSS transform preview before server-side processing. +``` + +--- + +## Task 7: Convert Tool + +### Files to create + +``` +apps/api/src/routes/tools/convert.ts # Tool config +apps/web/src/components/tools/convert-settings.tsx # Format picker, quality options +``` + +### Files to modify + +``` +apps/api/src/routes/tools/index.ts # Register convert route +apps/web/src/pages/tool-page.tsx # Add convert to toolSettingsMap +``` + +### Key interfaces + +```typescript +// routes/tools/convert.ts +const convertSettingsSchema = z.object({ + targetFormat: z.enum(['jpg', 'png', 'webp', 'avif', 'tiff', 'gif']), + quality: z.number().min(1).max(100).optional(), // for lossy formats + compressionLevel: z.number().min(0).max(9).optional(), // for PNG + lossless: z.boolean().optional(), // for WebP/AVIF +}); + +// components/tools/convert-settings.tsx +// - Source format: auto-detected, displayed as badge (e.g., "Source: PNG") +// - Target format: radio group or dropdown with format icons +// - Format-specific options shown conditionally: +// - JPG: quality slider (1-100, default 80) +// - PNG: compression level (0-9, default 6) +// - WebP: quality slider + lossless toggle +// - AVIF: quality slider (default 50) +// - TIFF: compression type dropdown (none, lzw, deflate) +``` + +### Steps + +- [ ] Create `apps/api/src/routes/tools/convert.ts` -- call `convert()` from image-engine with format and quality options. Set output filename extension to match target format +- [ ] Register convert in `apps/api/src/routes/tools/index.ts` +- [ ] Create `apps/web/src/components/tools/convert-settings.tsx` -- auto-detect source format from uploaded file metadata (returned by upload endpoint), render format radio group with icons for each supported output format, conditionally show format-specific quality controls. Display estimated output size when possible +- [ ] Add convert to `toolSettingsMap` in `apps/web/src/pages/tool-page.tsx` + +### Test + +```bash +# 1. Upload a JPG, convert to WebP, verify output is valid WebP +# 2. Upload a PNG with transparency, convert to JPG, verify alpha is composited on white +# 3. Convert to AVIF, verify quality slider works (small file at q=30, larger at q=80) +# 4. Convert PNG to PNG with compression level 9, verify file is smaller +# 5. Test lossless WebP toggle +``` + +### Commit + +``` +feat: add format conversion tool with auto-detection and per-format quality options + +Supports JPG, PNG, WebP, AVIF, TIFF, GIF output. Format-specific +quality controls shown conditionally (quality slider, lossless toggle, +compression level). +``` + +--- + +## Task 8: Compress Tool + +### Files to create + +``` +apps/api/src/routes/tools/compress.ts # Tool config +apps/web/src/components/tools/compress-settings.tsx # Quality vs target size mode +``` + +### Files to modify + +``` +apps/api/src/routes/tools/index.ts # Register compress route +apps/web/src/pages/tool-page.tsx # Add compress to toolSettingsMap +``` + +### Key interfaces + +```typescript +// routes/tools/compress.ts +const compressSettingsSchema = z.object({ + mode: z.enum(['quality', 'targetSize']), + quality: z.number().min(1).max(100).optional(), // used when mode=quality + targetSizeKB: z.number().min(1).max(102400).optional(), // used when mode=targetSize + outputFormat: z.enum(['jpg', 'png', 'webp', 'avif']).optional(), // keep original if not set +}); + +// components/tools/compress-settings.tsx +// - Mode toggle: [Quality] / [Target File Size] (segmented control) +// - Quality mode: slider 1-100 with labels (Low / Medium / High / Original) +// - Target size mode: number input + unit dropdown (KB / MB) +// - Before/after file size display: "4.2 MB -> ~890 KB (79% reduction)" +// (estimated from quality, confirmed after processing) +// - Output format selector (optional -- keep original format by default) +``` + +### Steps + +- [ ] Create `apps/api/src/routes/tools/compress.ts` -- two code paths: quality mode passes quality directly to `compress()` from image-engine; target-size mode passes `targetSizeBytes` which triggers binary search in the engine +- [ ] Register compress in `apps/api/src/routes/tools/index.ts` +- [ ] Create `apps/web/src/components/tools/compress-settings.tsx` -- segmented control for mode toggle, quality slider with labeled ticks, target size input with KB/MB unit toggle. After processing, show before/after comparison: original size, compressed size, percentage reduction, compression ratio +- [ ] Add compress to `toolSettingsMap` in `apps/web/src/pages/tool-page.tsx` + +### Test + +```bash +# 1. Upload a 5MB JPG +# 2. Quality mode: set quality=50, process, verify output is significantly smaller +# 3. Target size mode: set target=200KB, process, verify output is within ~10% of 200KB +# 4. Verify file size comparison shows correct values +# 5. Edge case: target size larger than original -- return original unchanged +# 6. Edge case: target size impossibly small -- return lowest quality result with warning +``` + +### Commit + +``` +feat: add compress tool with quality slider and target file size modes + +Binary search compression for target size (within 5% accuracy, max 8 +iterations). Before/after file size comparison in the UI. +``` + +--- + +## Task 9: Strip Metadata Tool + +### Files to create + +``` +apps/api/src/routes/tools/strip-metadata.ts # Tool config +apps/web/src/components/tools/strip-metadata-settings.tsx # Metadata field checkboxes +``` + +### Files to modify + +``` +apps/api/src/routes/tools/index.ts # Register strip-metadata route +apps/web/src/pages/tool-page.tsx # Add strip-metadata to toolSettingsMap +``` + +### Key interfaces + +```typescript +// routes/tools/strip-metadata.ts +const stripMetadataSettingsSchema = z.object({ + removeExif: z.boolean().default(true), + removeGps: z.boolean().default(true), + removeCameraInfo: z.boolean().default(true), + removeIccProfile: z.boolean().default(false), // ICC affects color -- default keep + removeXmp: z.boolean().default(true), + removeIptc: z.boolean().default(true), +}); + +// components/tools/strip-metadata-settings.tsx +// - Checkbox group with descriptions: +// [x] EXIF Data (camera settings, date, software) +// [x] GPS Location (latitude, longitude, altitude) +// [x] Camera Info (make, model, lens, serial number) +// [ ] ICC Color Profile (affects color accuracy -- caution) +// [x] XMP Data (editing history, keywords) +// [x] IPTC Data (copyright, caption, credits) +// - "Select All" / "Deselect All" buttons +// - Before/after metadata preview: show what will be removed +// - Warning when removing ICC profile +``` + +### Steps + +- [ ] Create `apps/api/src/routes/tools/strip-metadata.ts` -- call `stripMetadata()` from image-engine with the field flags. Return both the processed image and a diff of what metadata was removed +- [ ] Register strip-metadata in `apps/api/src/routes/tools/index.ts` +- [ ] Create `apps/web/src/components/tools/strip-metadata-settings.tsx` -- checkbox group with field descriptions, select all/none toggles. Before processing: show current metadata summary (fetched from image info). After processing: show removed fields in a collapsible diff +- [ ] Add strip-metadata to `toolSettingsMap` in `apps/web/src/pages/tool-page.tsx` + +### Test + +```bash +# 1. Upload a photo with rich EXIF (phone photo with GPS) +# 2. Check all boxes, process, verify metadata is stripped (inspect with exiftool or image info tool) +# 3. Uncheck ICC Profile, process, verify ICC is preserved but EXIF/GPS removed +# 4. Verify output image is visually identical to input +# 5. Verify file size is slightly smaller (metadata removed) +``` + +### Commit + +``` +feat: add strip metadata tool with selective field removal + +Checkboxes for EXIF, GPS, Camera, ICC, XMP, IPTC. Shows metadata +before/after diff. ICC removal warns about color accuracy impact. +``` + +--- + +## Task 10: Color Adjustments Tool + +Combines brightness, contrast, saturation, color channels, and color effects into a single comprehensive tool page (maps to PRD sections A-01 through A-04 under Adjustments). + +### Files to create + +``` +apps/api/src/routes/tools/color-adjustments.ts # Tool config +apps/web/src/components/tools/color-adjustments-settings.tsx # Tabbed settings: Adjust / Channels / Effects +``` + +### Files to modify + +``` +apps/api/src/routes/tools/index.ts # Register color adjustment routes +apps/web/src/pages/tool-page.tsx # Add all four adjustment tool IDs to toolSettingsMap +``` + +### Key interfaces + +```typescript +// routes/tools/color-adjustments.ts +// Handles four tool IDs: brightness-contrast, saturation, color-channels, color-effects +// They share one route handler since the operations are composable + +const colorAdjustmentsSchema = 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), + exposure: z.number().min(-100).max(100).default(0), + channelR: z.number().min(0).max(200).default(100), // percentage + channelG: z.number().min(0).max(200).default(100), + channelB: z.number().min(0).max(200).default(100), + effect: z.enum(['none', 'grayscale', 'sepia', 'invert']).default('none'), + effectIntensity: z.number().min(0).max(100).default(100), +}); + +// components/tools/color-adjustments-settings.tsx +// Three tabs or accordion sections: +// +// [Adjust] tab: +// - Brightness slider (-100 to +100, center=0) +// - Contrast slider (-100 to +100, center=0) +// - Saturation slider (-100 to +100, center=0) +// - Exposure slider (-100 to +100, center=0) +// - Reset All button +// +// [Channels] tab: +// - Red slider (0% to 200%, center=100%) +// - Green slider (0% to 200%, center=100%) +// - Blue slider (0% to 200%, center=100%) +// - Colored slider tracks (red/green/blue tinted) +// +// [Effects] tab: +// - Effect buttons: [Original] [Grayscale] [Sepia] [Invert] +// - Intensity slider (0-100%) for sepia +// - Active effect highlighted +``` + +### Steps + +- [ ] Create `apps/api/src/routes/tools/color-adjustments.ts` -- apply operations in deterministic order: brightness -> contrast -> saturation -> color channels -> effect. Use the individual operation functions from image-engine. Register four route variants (one per tool ID) that all use the same handler but with different default tab focus +- [ ] Register all four adjustment tool IDs in `apps/api/src/routes/tools/index.ts` +- [ ] Create `apps/web/src/components/tools/color-adjustments-settings.tsx` -- three-tab layout using shadcn Tabs component. Each slider shows its current value. Double-click a slider to reset to default. "Reset All" clears everything. When navigated to via `/brightness-contrast`, auto-select the Adjust tab; via `/color-channels`, auto-select Channels tab; via `/color-effects`, auto-select Effects tab +- [ ] Add all four adjustment tool IDs (`brightness-contrast`, `saturation`, `color-channels`, `color-effects`) to `toolSettingsMap` in `apps/web/src/pages/tool-page.tsx`, all pointing to the same `ColorAdjustmentsSettings` component with a `defaultTab` prop + +### Test + +```bash +# 1. Upload an image to /brightness-contrast +# 2. Drag brightness to +50, verify image appears brighter after processing +# 3. Navigate to /color-effects, apply Grayscale, verify output is grayscale +# 4. Apply Sepia at 50% intensity, verify tinted output +# 5. Navigate to /color-channels, set Red to 0%, verify red channel is removed +# 6. Apply multiple adjustments together: brightness +30, contrast +20, saturation -50 +# 7. Verify Reset All returns all sliders to default +``` + +### Commit + +``` +feat: add color adjustments tool with brightness, contrast, saturation, +channels, and effects (grayscale, sepia, invert) + +Tabbed settings UI serves four tool routes. Operations are composable +and applied in deterministic order. +``` + +--- + +## Task 11: Before/After Preview Component + +Reusable React component showing original vs processed image with a draggable split slider. Used by compress, color adjustments, and future tools where visual comparison matters. + +### Files to create + +``` +apps/web/src/components/common/before-after-preview.tsx # The slider component +apps/web/src/components/common/file-size-badge.tsx # "4.2 MB -> 890 KB (79%)" badge +apps/web/src/components/common/image-preview.tsx # Single image preview with zoom/pan +``` + +### Files to modify + +``` +apps/web/src/pages/tool-page.tsx # Replace static dropzone with preview when result exists +``` + +### Key interfaces + +```typescript +// components/common/before-after-preview.tsx +interface BeforeAfterPreviewProps { + beforeSrc: string; // object URL of original + afterSrc: string; // object URL of processed result + beforeLabel?: string; // default "Original" + afterLabel?: string; // default "Processed" + beforeSize?: number; // bytes + afterSize?: number; // bytes +} +// Renders two images stacked with CSS clip-path. A vertical divider bar +// is draggable left/right (mouse + touch). Left side shows "before" clipped +// to the divider position, right side shows "after". Labels in top corners. +// File size comparison badge at bottom. + +// components/common/file-size-badge.tsx +interface FileSizeBadgeProps { + originalBytes: number; + processedBytes: number; +} +// Renders: "4.2 MB -> 890 KB (79% smaller)" or "890 KB -> 1.2 MB (35% larger)" +// Green for reduction, amber for increase + +// components/common/image-preview.tsx +interface ImagePreviewProps { + src: string; + alt?: string; + maxHeight?: number; + onLoad?: (info: { width: number; height: number }) => void; +} +// Renders image with object-fit contain, optional zoom on scroll, pan on drag +``` + +### Steps + +- [ ] Create `apps/web/src/components/common/image-preview.tsx` -- simple image renderer with `object-fit: contain`, natural dimension detection via `onLoad`, and optional scroll-to-zoom +- [ ] Create `apps/web/src/components/common/file-size-badge.tsx` -- format bytes to human-readable (KB/MB), calculate percentage change, color-code (green for smaller, amber for larger) +- [ ] Create `apps/web/src/components/common/before-after-preview.tsx` -- implementation approach: two `` elements absolutely positioned in a container; left image clipped with `clip-path: inset(0 ${100-position}% 0 0)`, right image clipped with `clip-path: inset(0 0 0 ${position}%)`; draggable divider bar uses `onPointerDown/Move/Up` for mouse and touch support; position stored in state (default 50%). Include `FileSizeBadge` below the images +- [ ] Modify `apps/web/src/pages/tool-page.tsx` -- after processing completes, replace the dropzone area with `BeforeAfterPreview` showing original vs result. Add a "New Image" button to reset and show dropzone again. Add a "Download" button + +### Test + +```bash +# 1. Process any image with compress tool +# 2. Verify before/after slider appears showing both images +# 3. Drag the slider left and right, verify smooth clipping +# 4. Verify file size badge shows correct sizes and percentage +# 5. Test on mobile viewport -- verify touch dragging works +# 6. Click "New Image", verify dropzone reappears +# 7. Verify component works when images have different aspect ratios +``` + +### Commit + +``` +feat: add before/after preview slider with file size comparison + +Draggable split-view comparing original and processed images. Includes +file size badge showing reduction percentage. Mouse and touch support. +``` + +--- + +## Task 12: Batch Processing & ZIP Download + +Allow multiple files to be uploaded and processed through any tool. Return results as a ZIP file. Progress tracked via Server-Sent Events. + +### Files to create + +``` +apps/api/src/lib/job-queue.ts # p-queue wrapper with concurrency from env.CONCURRENT_JOBS +apps/api/src/routes/batch.ts # POST /api/v1/batch/:toolId, GET /api/v1/jobs/:jobId/progress (SSE) +apps/api/src/lib/zip.ts # Create ZIP from multiple output files using archiver +apps/web/src/components/common/batch-progress.tsx # Per-file progress bars with SSE listener +apps/web/src/hooks/use-sse.ts # Hook for consuming SSE endpoint +``` + +### Files to modify + +``` +apps/api/src/index.ts # Register batch routes +apps/api/package.json # Add p-queue, archiver dependencies +apps/web/src/pages/tool-page.tsx # Show batch progress UI when multiple files uploaded +apps/web/src/stores/file-store.ts # Add batch processing state (per-file progress) +apps/web/src/components/common/dropzone.tsx # Already supports multiple -- no changes needed +``` + +### Key interfaces + +```typescript +// lib/job-queue.ts +import PQueue from 'p-queue'; + +export const jobQueue: PQueue; // concurrency: env.CONCURRENT_JOBS +export interface QueuedJob { + jobId: string; + toolId: string; + files: string[]; + settings: Record; + progress: Map; +} +export function enqueueJob(job: QueuedJob): void; + +// routes/batch.ts +// POST /api/v1/batch/:toolId +// Body: multipart with multiple files + settings JSON +// Response: { jobId: string, totalFiles: number } +// +// GET /api/v1/jobs/:jobId/progress +// Response: SSE stream +// Events: +// data: { status: "processing", progress: 45, currentFile: "photo3.jpg", completedFiles: 4, totalFiles: 10 } +// data: { status: "completed", downloadUrl: "/api/v1/download/:jobId/results.zip" } +// data: { status: "failed", error: "...", failedFile: "photo7.psd" } +// +// GET /api/v1/download/:jobId/results.zip +// Response: ZIP file with all processed images + +// lib/zip.ts +export async function createZip(files: Array<{ path: string; name: string }>): Promise; + +// web hooks/use-sse.ts +export function useSSE(url: string | null): { + data: T | null; + error: string | null; + connected: boolean; +}; + +// web components/common/batch-progress.tsx +interface BatchProgressProps { + jobId: string; + totalFiles: number; + onComplete: (downloadUrl: string) => void; +} +// Renders: +// - Overall progress bar (X of Y files completed) +// - Per-file status list (filename + icon: pending/processing/done/failed) +// - Download ZIP button when complete +// - Partial failure notice with list of failed files +``` + +### Steps + +- [ ] Add `p-queue` and `archiver` to `apps/api/package.json`, add `@types/archiver` to devDependencies +- [ ] Create `apps/api/src/lib/job-queue.ts` -- instantiate `PQueue` with `concurrency: env.CONCURRENT_JOBS`. Export `enqueueJob` that adds a processing function to the queue. Store active jobs in a `Map` for progress lookup +- [ ] Create `apps/api/src/lib/zip.ts` -- use `archiver('zip')` to pack multiple files into a ZIP buffer. Accept an array of `{ path, name }` objects +- [ ] Create `apps/api/src/routes/batch.ts` -- batch endpoint: accept multiple files via multipart, generate jobId, create workspace, enqueue per-file processing tasks. Each task calls the same `process` function from the tool config. As each file completes, update the job's progress map. SSE endpoint: register on `GET /api/v1/jobs/:jobId/progress`, set `Content-Type: text/event-stream`, push events as files complete. When all files done, create ZIP in workspace and send final `completed` event with download URL. Handle partial failures: continue processing remaining files, report failed ones +- [ ] Create `apps/web/src/hooks/use-sse.ts` -- wrap `EventSource` in a React hook. Connect when URL is provided, parse `data` field as JSON, expose latest data and connection state. Clean up on unmount +- [ ] Create `apps/web/src/components/common/batch-progress.tsx` -- consume `useSSE` hook, render overall progress bar and per-file status list. "Download ZIP" button triggers `apiDownloadBlob` +- [ ] Update `apps/web/src/stores/file-store.ts` -- add batch state: `batchJobId`, `batchProgress` map, `isBatchMode` flag (true when files.length > 1) +- [ ] Modify `apps/web/src/pages/tool-page.tsx` -- when multiple files are uploaded, show "Process All (N files)" button instead of single-file process. After clicking, show `BatchProgress` component. Wire download to blob trigger +- [ ] Modify `apps/api/src/index.ts` -- register batch routes + +### Test + +```bash +# 1. Navigate to /resize +# 2. Upload 5 images at once +# 3. Set resize to 500x500 +# 4. Click "Process All (5 files)" +# 5. Verify progress bar advances per file +# 6. Verify SSE events stream correctly (open DevTools Network tab -> EventStream) +# 7. On completion, click "Download ZIP" +# 8. Extract ZIP, verify all 5 images are 500x500 +# 9. Test partial failure: include one invalid file (e.g., .txt renamed to .jpg) +# 10. Verify remaining files still process and failed file is reported +``` + +### Commit + +``` +feat: add batch processing with ZIP download and SSE progress tracking + +Multiple files processed via p-queue with configurable concurrency. +Progress streamed via Server-Sent Events. Results packaged as ZIP. +Partial failure handling continues processing and reports failed files. +``` + +--- + +## Dependency Graph + +``` +Task 1 (Image Engine) + └─> Task 2 (Upload/Download) + └─> Task 3 (Route Factory) + ├─> Task 4 (Resize) ─> Task 11 (Before/After Preview) + ├─> Task 5 (Crop) + ├─> Task 6 (Rotate) + ├─> Task 7 (Convert) + ├─> Task 8 (Compress) ─> Task 11 (Before/After Preview) + ├─> Task 9 (Metadata) + └─> Task 10 (Color) ─> Task 11 (Before/After Preview) + └─> Task 12 (Batch + ZIP) +``` + +Tasks 4-10 can be built in parallel once Task 3 is done. Task 11 can be built alongside tasks 4-10 but should be wired in after at least one tool exists. Task 12 depends on everything else. + +## Total New Files + +| Area | Count | +|------|-------| +| `packages/image-engine/src/` | 18 files (engine, types, 14 operations, format detect, 2 utils) | +| `apps/api/src/routes/` | 10 files (factory, 7 tool routes, batch, files) | +| `apps/api/src/lib/` | 4 files (workspace, file-validation, job-queue, zip) | +| `apps/api/src/plugins/` | 1 file (upload) | +| `apps/web/src/components/tools/` | 7 files (settings for each tool) | +| `apps/web/src/components/common/` | 4 files (image-cropper, before-after, file-size-badge, image-preview, batch-progress) | +| `apps/web/src/stores/` | 1 file (file-store) | +| `apps/web/src/hooks/` | 2 files (use-tool-processor, use-sse) | +| Tests | 1 file (image-engine operations) | +| **Total** | **~48 files** | diff --git a/packages/image-engine/package.json b/packages/image-engine/package.json index f6129b9d..c954f652 100644 --- a/packages/image-engine/package.json +++ b/packages/image-engine/package.json @@ -7,12 +7,15 @@ "types": "./src/index.ts", "scripts": { "typecheck": "tsc --noEmit", + "test": "vitest run", "clean": "rm -rf dist" }, "dependencies": { - "@stirling-image/shared": "workspace:*" + "@stirling-image/shared": "workspace:*", + "sharp": "^0.33.0" }, "devDependencies": { - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vitest": "^3.0.0" } } diff --git a/packages/image-engine/src/engine.ts b/packages/image-engine/src/engine.ts new file mode 100644 index 00000000..f6eee0fe --- /dev/null +++ b/packages/image-engine/src/engine.ts @@ -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; +} + +const OPERATION_MAP: Record< + string, + (image: Sharp, options: Record) => Promise +> = { + 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 = { + 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 { + 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 }; +} diff --git a/packages/image-engine/src/formats/detect.ts b/packages/image-engine/src/formats/detect.ts new file mode 100644 index 00000000..1a9943d5 --- /dev/null +++ b/packages/image-engine/src/formats/detect.ts @@ -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 { + 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"; +} diff --git a/packages/image-engine/src/index.ts b/packages/image-engine/src/index.ts index 1c5444ad..4c4e603c 100644 --- a/packages/image-engine/src/index.ts +++ b/packages/image-engine/src/index.ts @@ -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"; diff --git a/packages/image-engine/src/operations/brightness.ts b/packages/image-engine/src/operations/brightness.ts new file mode 100644 index 00000000..a3f63b17 --- /dev/null +++ b/packages/image-engine/src/operations/brightness.ts @@ -0,0 +1,15 @@ +import type { Sharp, BrightnessOptions } from "../types.js"; + +export async function brightness(image: Sharp, options: BrightnessOptions): Promise { + 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 }); +} diff --git a/packages/image-engine/src/operations/color-channels.ts b/packages/image-engine/src/operations/color-channels.ts new file mode 100644 index 00000000..05dd7cab --- /dev/null +++ b/packages/image-engine/src/operations/color-channels.ts @@ -0,0 +1,35 @@ +import type { Sharp, ColorChannelOptions } from "../types.js"; + +export async function colorChannels( + image: Sharp, + options: ColorChannelOptions +): Promise { + 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); +} diff --git a/packages/image-engine/src/operations/compress.ts b/packages/image-engine/src/operations/compress.ts new file mode 100644 index 00000000..7b5206ad --- /dev/null +++ b/packages/image-engine/src/operations/compress.ts @@ -0,0 +1,80 @@ +import sharp from "sharp"; +import type { Sharp, CompressOptions, OutputFormat } from "../types.js"; + +const FORMAT_MAP: Record = { + jpg: "jpeg", + png: "png", + webp: "webp", + avif: "avif", + tiff: "tiff", + gif: "gif", +}; + +export async function compress(image: Sharp, options: CompressOptions): Promise { + 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 { + // 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); +} diff --git a/packages/image-engine/src/operations/contrast.ts b/packages/image-engine/src/operations/contrast.ts new file mode 100644 index 00000000..1bd9fa76 --- /dev/null +++ b/packages/image-engine/src/operations/contrast.ts @@ -0,0 +1,17 @@ +import type { Sharp, ContrastOptions } from "../types.js"; + +export async function contrast(image: Sharp, options: ContrastOptions): Promise { + 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); +} diff --git a/packages/image-engine/src/operations/convert.ts b/packages/image-engine/src/operations/convert.ts new file mode 100644 index 00000000..2e870b31 --- /dev/null +++ b/packages/image-engine/src/operations/convert.ts @@ -0,0 +1,29 @@ +import type { Sharp, ConvertOptions, OutputFormat } from "../types.js"; + +const FORMAT_MAP: Record = { + jpg: "jpeg", + png: "png", + webp: "webp", + avif: "avif", + tiff: "tiff", + gif: "gif", +}; + +export async function convert(image: Sharp, options: ConvertOptions): Promise { + const { format, quality } = options; + + const sharpFormat = FORMAT_MAP[format]; + if (!sharpFormat) { + throw new Error(`Unsupported output format: ${format}`); + } + + const formatOptions: Record = {}; + 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); +} diff --git a/packages/image-engine/src/operations/crop.ts b/packages/image-engine/src/operations/crop.ts new file mode 100644 index 00000000..83ff4658 --- /dev/null +++ b/packages/image-engine/src/operations/crop.ts @@ -0,0 +1,29 @@ +import type { Sharp, CropOptions } from "../types.js"; + +export async function crop(image: Sharp, options: CropOptions): Promise { + 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 }); +} diff --git a/packages/image-engine/src/operations/flip.ts b/packages/image-engine/src/operations/flip.ts new file mode 100644 index 00000000..306a2a23 --- /dev/null +++ b/packages/image-engine/src/operations/flip.ts @@ -0,0 +1,21 @@ +import type { Sharp, FlipOptions } from "../types.js"; + +export async function flip(image: Sharp, options: FlipOptions): Promise { + 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; +} diff --git a/packages/image-engine/src/operations/grayscale.ts b/packages/image-engine/src/operations/grayscale.ts new file mode 100644 index 00000000..6020be1c --- /dev/null +++ b/packages/image-engine/src/operations/grayscale.ts @@ -0,0 +1,5 @@ +import type { Sharp } from "../types.js"; + +export async function grayscale(image: Sharp): Promise { + return image.grayscale(); +} diff --git a/packages/image-engine/src/operations/invert.ts b/packages/image-engine/src/operations/invert.ts new file mode 100644 index 00000000..d35d100d --- /dev/null +++ b/packages/image-engine/src/operations/invert.ts @@ -0,0 +1,5 @@ +import type { Sharp } from "../types.js"; + +export async function invert(image: Sharp): Promise { + return image.negate(); +} diff --git a/packages/image-engine/src/operations/resize.ts b/packages/image-engine/src/operations/resize.ts new file mode 100644 index 00000000..e5962712 --- /dev/null +++ b/packages/image-engine/src/operations/resize.ts @@ -0,0 +1,33 @@ +import type { Sharp, ResizeOptions } from "../types.js"; + +export async function resize(image: Sharp, options: ResizeOptions): Promise { + 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, + }); +} diff --git a/packages/image-engine/src/operations/rotate.ts b/packages/image-engine/src/operations/rotate.ts new file mode 100644 index 00000000..51ed9d5a --- /dev/null +++ b/packages/image-engine/src/operations/rotate.ts @@ -0,0 +1,15 @@ +import type { Sharp, RotateOptions } from "../types.js"; + +export async function rotate(image: Sharp, options: RotateOptions): Promise { + const { angle, background } = options; + + const isMultipleOf90 = angle % 90 === 0; + + if (isMultipleOf90) { + return image.rotate(angle); + } + + return image.rotate(angle, { + background: background ?? "#000000", + }); +} diff --git a/packages/image-engine/src/operations/saturation.ts b/packages/image-engine/src/operations/saturation.ts new file mode 100644 index 00000000..1ee03a1a --- /dev/null +++ b/packages/image-engine/src/operations/saturation.ts @@ -0,0 +1,15 @@ +import type { Sharp, SaturationOptions } from "../types.js"; + +export async function saturation(image: Sharp, options: SaturationOptions): Promise { + 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 }); +} diff --git a/packages/image-engine/src/operations/sepia.ts b/packages/image-engine/src/operations/sepia.ts new file mode 100644 index 00000000..2b561ad6 --- /dev/null +++ b/packages/image-engine/src/operations/sepia.ts @@ -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 { + return image.recomb(SEPIA_MATRIX); +} diff --git a/packages/image-engine/src/operations/strip-metadata.ts b/packages/image-engine/src/operations/strip-metadata.ts new file mode 100644 index 00000000..9452eea6 --- /dev/null +++ b/packages/image-engine/src/operations/strip-metadata.ts @@ -0,0 +1,38 @@ +import type { Sharp, StripMetadataOptions } from "../types.js"; + +export async function stripMetadata( + image: Sharp, + options: StripMetadataOptions = {} +): Promise { + 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 ? {} : {}), + }); +} diff --git a/packages/image-engine/src/types.ts b/packages/image-engine/src/types.ts new file mode 100644 index 00000000..ea7b0aba --- /dev/null +++ b/packages/image-engine/src/types.ts @@ -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; +} + +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 +} diff --git a/packages/image-engine/src/utils/metadata.ts b/packages/image-engine/src/utils/metadata.ts new file mode 100644 index 00000000..f2b1b1f0 --- /dev/null +++ b/packages/image-engine/src/utils/metadata.ts @@ -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 { + 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, + }, + }; +} diff --git a/packages/image-engine/src/utils/mime.ts b/packages/image-engine/src/utils/mime.ts new file mode 100644 index 00000000..b4a47f82 --- /dev/null +++ b/packages/image-engine/src/utils/mime.ts @@ -0,0 +1,63 @@ +const EXT_TO_MIME: Record = { + 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 = { + "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; +} diff --git a/packages/image-engine/tests/operations.test.ts b/packages/image-engine/tests/operations.test.ts new file mode 100644 index 00000000..b013367c --- /dev/null +++ b/packages/image-engine/tests/operations.test.ts @@ -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"); + }); +}); diff --git a/packages/image-engine/vitest.config.ts b/packages/image-engine/vitest.config.ts new file mode 100644 index 00000000..e2ec3329 --- /dev/null +++ b/packages/image-engine/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a40958b..9741034f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,9 +35,15 @@ importers: '@fastify/swagger-ui': specifier: ^5.2.0 version: 5.2.5 + '@stirling-image/image-engine': + specifier: workspace:* + version: link:../../packages/image-engine '@stirling-image/shared': specifier: workspace:* version: link:../../packages/shared + archiver: + specifier: ^7.0.1 + version: 7.0.1 better-sqlite3: specifier: ^11.7.0 version: 11.10.0 @@ -50,10 +56,19 @@ importers: fastify: specifier: ^5.2.0 version: 5.8.2 + p-queue: + specifier: ^9.1.0 + version: 9.1.0 + sharp: + specifier: ^0.33.0 + version: 0.33.5 zod: specifier: ^3.24.0 version: 3.25.76 devDependencies: + '@types/archiver': + specifier: ^7.0.0 + version: 7.0.0 '@types/better-sqlite3': specifier: ^7.6.0 version: 7.6.13 @@ -134,10 +149,16 @@ importers: '@stirling-image/shared': specifier: workspace:* version: link:../shared + sharp: + specifier: ^0.33.0 + version: 0.33.5 devDependencies: typescript: specifier: ^5.7.0 version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) packages/shared: devDependencies: @@ -233,6 +254,9 @@ packages: '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@emnapi/runtime@1.9.1': + resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} + '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.is' @@ -874,6 +898,115 @@ packages: '@fastify/swagger@9.7.0': resolution: {integrity: sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==} + '@img/sharp-darwin-arm64@0.33.5': + resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.33.5': + resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.0.4': + resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.0.4': + resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.0.4': + resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.0.5': + resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.0.4': + resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.0.4': + resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.33.5': + resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.33.5': + resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-s390x@0.33.5': + resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.33.5': + resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.33.5': + resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.33.5': + resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.33.5': + resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-ia32@0.33.5': + resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.33.5': + resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@isaacs/cliui@9.0.0': resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} engines: {node: '>=18'} @@ -904,6 +1037,10 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -1152,6 +1289,9 @@ packages: cpu: [arm64] os: [win32] + '@types/archiver@7.0.0': + resolution: {integrity: sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1167,6 +1307,12 @@ packages: '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1181,12 +1327,48 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/readdir-glob@1.1.5': + resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} @@ -1201,6 +1383,37 @@ packages: ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + archiver-utils@5.0.2: + resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} + engines: {node: '>= 14'} + + archiver@7.0.1: + resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} + engines: {node: '>= 14'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -1208,10 +1421,59 @@ packages: avvio@9.2.0: resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + b4a@1.8.0: + resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.5.6: + resolution: {integrity: sha512-1QovqDrR80Pmt5HPAsMsXTCFcDYr+NSUKW6nd6WO5v0JBmnItc/irNRzm2KOQ5oZ69P37y+AMujNyNtG+1Rggw==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.8.0: + resolution: {integrity: sha512-Dc9/SlwfxkXIGYhvMQNUtKaXCaGkZYGcd1vuNUUADVqzu4/vQfvnMkYYOUnt2VwQ2AqKr/8qAVFRtwETljgeFg==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.0: + resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + + bare-stream@2.10.0: + resolution: {integrity: sha512-DOPZF/DDcDruKDA43cOw6e9Quq5daua7ygcAwJE/pKJsRWhgSSemi7qVNGE5kyDIxIeN1533G/zfbvWX7Wcb9w==} + peerDependencies: + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.0: + resolution: {integrity: sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -1229,6 +1491,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.4: resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} engines: {node: 18 || 20 || >=22} @@ -1238,15 +1503,34 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + caniuse-lite@1.0.30001780: resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} @@ -1254,6 +1538,24 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + + compress-commons@6.0.2: + resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} + engines: {node: '>= 14'} + content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -1269,6 +1571,18 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@6.0.0: + resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} + engines: {node: '>= 14'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1289,6 +1603,10 @@ packages: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -1405,9 +1723,18 @@ packages: sqlite3: optional: true + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + electron-to-chromium@1.5.321: resolution: {integrity: sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -1419,6 +1746,9 @@ packages: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + esbuild-register@3.6.0: resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} peerDependencies: @@ -1451,16 +1781,40 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-json-stringify@6.3.0: resolution: {integrity: sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==} @@ -1522,6 +1876,11 @@ packages: github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} @@ -1552,6 +1911,20 @@ packages: resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1559,6 +1932,9 @@ packages: resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} engines: {node: '>=18'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jackspeak@4.2.3: resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} engines: {node: 20 || >=22} @@ -1570,6 +1946,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1590,6 +1969,10 @@ packages: engines: {node: '>=6'} hasBin: true + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} @@ -1663,6 +2046,15 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.7: resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} engines: {node: 20 || >=22} @@ -1691,6 +2083,14 @@ packages: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -1719,6 +2119,10 @@ packages: node-releases@2.0.36: resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} @@ -1729,6 +2133,14 @@ packages: openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + p-queue@9.1.0: + resolution: {integrity: sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==} + engines: {node: '>=20'} + + p-timeout@7.0.1: + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} + engines: {node: '>=20'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -1736,10 +2148,21 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1767,12 +2190,19 @@ packages: deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process-warning@4.0.1: resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -1813,10 +2243,20 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -1844,6 +2284,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -1876,6 +2319,10 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.33.5: + resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1888,6 +2335,9 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -1898,6 +2348,9 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -1916,17 +2369,48 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + streamx@2.25.0: + resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + tailwind-merge@2.6.1: resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} @@ -1944,14 +2428,41 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + tar-stream@3.1.8: + resolution: {integrity: sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + thread-stream@4.0.0: resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} engines: {node: '>=20'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + toad-cache@3.7.0: resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} engines: {node: '>=12'} @@ -1960,6 +2471,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.21.0: resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} engines: {node: '>=18.0.0'} @@ -1989,6 +2503,11 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite@6.4.1: resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -2029,6 +2548,34 @@ packages: yaml: optional: true + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2039,6 +2586,19 @@ packages: engines: {node: ^16.13.0 || >=18.0.0} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -2050,6 +2610,10 @@ packages: engines: {node: '>= 14.6'} hasBin: true + zip-stream@6.0.1: + resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} + engines: {node: '>= 14'} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -2187,6 +2751,11 @@ snapshots: '@drizzle-team/brocli@0.10.2': {} + '@emnapi/runtime@1.9.1': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild-kit/core-utils@3.3.2': dependencies: esbuild: 0.18.20 @@ -2580,6 +3149,90 @@ snapshots: transitivePeerDependencies: - supports-color + '@img/sharp-darwin-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.0.4 + optional: true + + '@img/sharp-darwin-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.0.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.0.5': + optional: true + + '@img/sharp-libvips-linux-s390x@1.0.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + optional: true + + '@img/sharp-linux-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.0.4 + optional: true + + '@img/sharp-linux-arm@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.0.5 + optional: true + + '@img/sharp-linux-s390x@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.0.4 + optional: true + + '@img/sharp-linux-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + optional: true + + '@img/sharp-wasm32@0.33.5': + dependencies: + '@emnapi/runtime': 1.9.1 + optional: true + + '@img/sharp-win32-ia32@0.33.5': + optional: true + + '@img/sharp-win32-x64@0.33.5': + optional: true + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/cliui@9.0.0': {} '@jridgewell/gen-mapping@0.3.13': @@ -2607,6 +3260,9 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.59.1': @@ -2770,6 +3426,10 @@ snapshots: '@turbo/windows-arm64@2.8.20': optional: true + '@types/archiver@7.0.0': + dependencies: + '@types/readdir-glob': 1.1.5 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.2 @@ -2795,6 +3455,13 @@ snapshots: dependencies: '@types/node': 22.19.15 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/node@22.19.15': @@ -2809,6 +3476,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/readdir-glob@1.1.5': + dependencies: + '@types/node': 22.19.15 + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@babel/core': 7.29.0 @@ -2821,6 +3492,52 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + abstract-logging@2.0.1: {} ajv-formats@3.0.1(ajv@8.18.0): @@ -2834,6 +3551,44 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + archiver-utils@5.0.2: + dependencies: + glob: 10.5.0 + graceful-fs: 4.2.11 + is-stream: 2.0.1 + lazystream: 1.0.1 + lodash: 4.17.23 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + + archiver@7.0.1: + dependencies: + archiver-utils: 5.0.2 + async: 3.2.6 + buffer-crc32: 1.0.0 + readable-stream: 4.7.0 + readdir-glob: 1.1.3 + tar-stream: 3.1.8 + zip-stream: 6.0.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + assertion-error@2.0.1: {} + + async@3.2.6: {} + atomic-sleep@1.0.0: {} avvio@9.2.0: @@ -2841,8 +3596,45 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 + b4a@1.8.0: {} + + balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + bare-events@2.8.2: {} + + bare-fs@4.5.6: + dependencies: + bare-events: 2.8.2 + bare-path: 3.0.0 + bare-stream: 2.10.0(bare-events@2.8.2) + bare-url: 2.4.0 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-os@3.8.0: {} + + bare-path@3.0.0: + dependencies: + bare-os: 3.8.0 + + bare-stream@2.10.0(bare-events@2.8.2): + dependencies: + streamx: 2.25.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-url@2.4.0: + dependencies: + bare-path: 3.0.0 + base64-js@1.5.1: {} baseline-browser-mapping@2.10.10: {} @@ -2862,6 +3654,10 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.4: dependencies: balanced-match: 4.0.4 @@ -2874,6 +3670,8 @@ snapshots: node-releases: 2.0.36 update-browserslist-db: 1.2.3(browserslist@4.28.1) + buffer-crc32@1.0.0: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -2881,12 +3679,53 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + cac@6.7.14: {} + caniuse-lite@1.0.30001780: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + chownr@1.1.4: {} clsx@2.1.1: {} + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + + compress-commons@6.0.2: + dependencies: + crc-32: 1.2.2 + crc32-stream: 6.0.0 + is-stream: 2.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + content-disposition@0.5.4: dependencies: safe-buffer: 5.2.1 @@ -2897,6 +3736,15 @@ snapshots: cookie@1.1.1: {} + core-util-is@1.0.3: {} + + crc-32@1.2.2: {} + + crc32-stream@6.0.0: + dependencies: + crc-32: 1.2.2 + readable-stream: 4.7.0 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2913,6 +3761,8 @@ snapshots: dependencies: mimic-response: 3.1.0 + deep-eql@5.0.2: {} + deep-extend@0.6.0: {} depd@2.0.0: {} @@ -2940,8 +3790,14 @@ snapshots: better-sqlite3: 11.10.0 react: 19.2.4 + eastasianwidth@0.2.0: {} + electron-to-chromium@1.5.321: {} + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -2953,6 +3809,8 @@ snapshots: env-paths@3.0.0: {} + es-module-lexer@1.7.0: {} + esbuild-register@3.6.0(esbuild@0.19.12): dependencies: debug: 4.4.3 @@ -3073,12 +3931,32 @@ snapshots: escape-html@1.0.3: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + event-target-shim@5.0.1: {} + + eventemitter3@5.0.4: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + + events@3.3.0: {} + expand-template@2.0.3: {} + expect-type@1.3.0: {} + fast-decode-uri-component@1.0.1: {} fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-json-stringify@6.3.0: dependencies: '@fastify/merge-json-schemas': 0.2.1 @@ -3159,6 +4037,15 @@ snapshots: github-from-package@0.0.0: {} + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@11.1.0: dependencies: foreground-child: 3.3.1 @@ -3192,10 +4079,24 @@ snapshots: ipaddr.js@2.3.0: {} + is-arrayish@0.3.4: {} + + is-fullwidth-code-point@3.0.0: {} + + is-stream@2.0.1: {} + + isarray@1.0.0: {} + isexe@2.0.0: {} isexe@3.1.5: {} + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jackspeak@4.2.3: dependencies: '@isaacs/cliui': 9.0.0 @@ -3204,6 +4105,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + jsesc@3.1.0: {} json-schema-ref-resolver@3.0.0: @@ -3222,6 +4125,10 @@ snapshots: json5@2.2.3: {} + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + light-my-request@6.6.0: dependencies: cookie: 1.1.1 @@ -3277,6 +4184,12 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lodash@4.17.23: {} + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + lru-cache@11.2.7: {} lru-cache@5.1.1: @@ -3299,6 +4212,14 @@ snapshots: dependencies: brace-expansion: 5.0.4 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.0.2 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.0.2 + minimist@1.2.8: {} minipass@7.1.3: {} @@ -3317,6 +4238,8 @@ snapshots: node-releases@2.0.36: {} + normalize-path@3.0.0: {} + on-exit-leak-free@2.1.2: {} once@1.4.0: @@ -3325,15 +4248,31 @@ snapshots: openapi-types@12.1.3: {} + p-queue@9.1.0: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.1 + + p-timeout@7.0.1: {} + package-json-from-dist@1.0.1: {} path-key@3.1.1: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-scurry@2.0.2: dependencies: lru-cache: 11.2.7 minipass: 7.1.3 + pathe@2.0.3: {} + + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@4.0.3: {} @@ -3379,10 +4318,14 @@ snapshots: tar-fs: 2.1.4 tunnel-agent: 0.6.0 + process-nextick-args@2.0.1: {} + process-warning@4.0.1: {} process-warning@5.0.0: {} + process@0.11.10: {} + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -3420,12 +4363,34 @@ snapshots: react@19.2.4: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.9 + real-require@0.2.0: {} require-from-string@2.0.2: {} @@ -3469,6 +4434,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.1 fsevents: 2.3.3 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safe-regex2@5.1.0: @@ -3489,6 +4456,32 @@ snapshots: setprototypeof@1.2.0: {} + sharp@0.33.5: + dependencies: + color: 4.2.3 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.33.5 + '@img/sharp-darwin-x64': 0.33.5 + '@img/sharp-libvips-darwin-arm64': 1.0.4 + '@img/sharp-libvips-darwin-x64': 1.0.4 + '@img/sharp-libvips-linux-arm': 1.0.5 + '@img/sharp-libvips-linux-arm64': 1.0.4 + '@img/sharp-libvips-linux-s390x': 1.0.4 + '@img/sharp-libvips-linux-x64': 1.0.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + '@img/sharp-linux-arm': 0.33.5 + '@img/sharp-linux-arm64': 0.33.5 + '@img/sharp-linux-s390x': 0.33.5 + '@img/sharp-linux-x64': 0.33.5 + '@img/sharp-linuxmusl-arm64': 0.33.5 + '@img/sharp-linuxmusl-x64': 0.33.5 + '@img/sharp-wasm32': 0.33.5 + '@img/sharp-win32-ia32': 0.33.5 + '@img/sharp-win32-x64': 0.33.5 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -3497,6 +4490,8 @@ snapshots: shell-quote@1.8.3: {} + siginfo@2.0.0: {} + signal-exit@4.1.0: {} simple-concat@1.0.1: {} @@ -3507,6 +4502,10 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -3522,14 +4521,55 @@ snapshots: split2@4.2.0: {} + stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@3.10.0: {} + + streamx@2.25.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-json-comments@2.0.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + tailwind-merge@2.6.1: {} tailwindcss@4.2.2: {} @@ -3551,19 +4591,56 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + tar-stream@3.1.8: + dependencies: + b4a: 1.8.0 + bare-fs: 4.5.6 + fast-fifo: 1.3.2 + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.0 + transitivePeerDependencies: + - react-native-b4a + thread-stream@4.0.0: dependencies: real-require: 0.2.0 + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + toad-cache@3.7.0: {} toidentifier@1.0.1: {} + tslib@2.8.1: + optional: true + tsx@4.21.0: dependencies: esbuild: 0.27.4 @@ -3596,6 +4673,27 @@ snapshots: util-deprecate@1.0.2: {} + vite-node@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.25.12 @@ -3612,6 +4710,47 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 + vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + vite-node: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.15 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + which@2.0.2: dependencies: isexe: 2.0.0 @@ -3620,12 +4759,35 @@ snapshots: dependencies: isexe: 3.1.5 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} yallist@3.1.1: {} yaml@2.8.3: {} + zip-stream@6.0.1: + dependencies: + archiver-utils: 5.0.2 + compress-commons: 6.0.2 + readable-stream: 4.7.0 + zod@3.25.76: {} zustand@5.0.12(@types/react@19.2.14)(react@19.2.4):