diff --git a/apps/api/src/routes/tools/index.ts b/apps/api/src/routes/tools/index.ts index 75673a20..974eb617 100644 --- a/apps/api/src/routes/tools/index.ts +++ b/apps/api/src/routes/tools/index.ts @@ -28,6 +28,7 @@ import { registerImageToPdf } from "./image-to-pdf.js"; import { registerInfo } from "./info.js"; import { registerNoiseRemoval } from "./noise-removal.js"; import { registerOcr } from "./ocr.js"; +import { registerOptimizeForWeb } from "./optimize-for-web.js"; import { registerPassportPhoto } from "./passport-photo.js"; import { registerPdfToImage } from "./pdf-to-image.js"; import { registerQrGenerate } from "./qr-generate.js"; @@ -125,6 +126,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise { { id: "bulk-rename", register: registerBulkRename }, { id: "favicon", register: registerFavicon }, { id: "image-to-pdf", register: registerImageToPdf }, + { id: "optimize-for-web", register: registerOptimizeForWeb }, // Adjustments extra { id: "replace-color", register: registerReplaceColor }, diff --git a/apps/api/src/routes/tools/optimize-for-web.ts b/apps/api/src/routes/tools/optimize-for-web.ts new file mode 100644 index 00000000..5c5ebcfb --- /dev/null +++ b/apps/api/src/routes/tools/optimize-for-web.ts @@ -0,0 +1,157 @@ +import { extname } from "node:path"; +import { optimizeForWeb } from "@stirling-image/image-engine"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import sharp from "sharp"; +import { z } from "zod"; +import { autoOrient } from "../../lib/auto-orient.js"; +import { validateImageBuffer } from "../../lib/file-validation.js"; +import { sanitizeFilename } from "../../lib/filename.js"; +import { decodeHeic } from "../../lib/heic-converter.js"; +import { sanitizeSvg } from "../../lib/svg-sanitize.js"; +import { createToolRoute } from "../tool-factory.js"; + +const FORMAT_CONTENT_TYPES: Record = { + webp: "image/webp", + jpeg: "image/jpeg", + avif: "image/avif", + png: "image/png", +}; + +const FORMAT_EXTENSIONS: Record = { + webp: "webp", + jpeg: "jpg", + avif: "avif", + png: "png", +}; + +const settingsSchema = z.object({ + format: z.enum(["webp", "jpeg", "avif", "png"]).default("webp"), + quality: z.number().min(1).max(100).default(80), + maxWidth: z.number().positive().optional(), + maxHeight: z.number().positive().optional(), + progressive: z.boolean().default(true), + stripMetadata: z.boolean().default(true), +}); + +type Settings = z.infer; + +async function processImage(inputBuffer: Buffer, settings: Settings, filename: string) { + const image = sharp(inputBuffer); + const result = await optimizeForWeb(image, settings); + const buffer = await result.toBuffer(); + + const ext = extname(filename); + const baseName = ext ? filename.slice(0, -ext.length) : filename; + const outputFilename = `${baseName}.${FORMAT_EXTENSIONS[settings.format]}`; + const contentType = FORMAT_CONTENT_TYPES[settings.format]; + + return { buffer, filename: outputFilename, contentType }; +} + +export function registerOptimizeForWeb(app: FastifyInstance) { + // Lightweight preview route for live parameter tuning + app.post( + "/api/v1/tools/optimize-for-web/preview", + async (request: FastifyRequest, reply: FastifyReply) => { + let fileBuffer: Buffer | null = null; + let filename = "image"; + 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); + } + fileBuffer = Buffer.concat(chunks); + 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 (!fileBuffer || fileBuffer.length === 0) { + return reply.status(400).send({ error: "No image file provided" }); + } + + const validation = await validateImageBuffer(fileBuffer); + if (!validation.valid) { + return reply.status(400).send({ error: `Invalid image: ${validation.reason}` }); + } + + // Decode HEIC/HEIF + if (validation.format === "heif") { + try { + fileBuffer = await decodeHeic(fileBuffer); + } catch (err) { + return reply.status(422).send({ + error: "Failed to decode HEIC file", + details: err instanceof Error ? err.message : String(err), + }); + } + } + + // Sanitize SVG + if (validation.format === "svg") { + try { + fileBuffer = sanitizeSvg(fileBuffer); + } catch (err) { + return reply.status(400).send({ + error: err instanceof Error ? err.message : "Invalid SVG", + }); + } + } + + let settings: Settings; + try { + const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; + const result = 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" }); + } + + try { + const processBuffer = + validation.format === "svg" ? fileBuffer : await autoOrient(fileBuffer); + const result = await processImage(processBuffer, settings, filename); + + // Return the optimized image directly as binary with size headers. + // This avoids workspace creation for ephemeral previews. + reply.header("Content-Type", result.contentType); + reply.header("X-Original-Size", String(fileBuffer.length)); + reply.header("X-Processed-Size", String(result.buffer.length)); + reply.header("X-Output-Filename", result.filename); + return reply.send(result.buffer); + } catch (err) { + const message = err instanceof Error ? err.message : "Preview processing failed"; + request.log.error({ err }, "Optimize preview failed"); + return reply.status(422).send({ error: "Preview failed", details: message }); + } + }, + ); + + // Standard processing route via tool factory + createToolRoute(app, { + toolId: "optimize-for-web", + settingsSchema, + process: processImage, + }); +} diff --git a/apps/web/src/components/tools/optimize-for-web-settings.tsx b/apps/web/src/components/tools/optimize-for-web-settings.tsx new file mode 100644 index 00000000..1676bd35 --- /dev/null +++ b/apps/web/src/components/tools/optimize-for-web-settings.tsx @@ -0,0 +1,358 @@ +import { ChevronDown, ChevronRight, Download, Loader2 } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { formatHeaders } from "@/lib/api"; +import { useFileStore } from "@/stores/file-store"; + +type WebFormat = "webp" | "jpeg" | "avif" | "png"; + +interface PreviewState { + loading: boolean; + previewUrl: string | null; + processedSize: number | null; + originalSize: number | null; +} + +const FORMAT_LABELS: Record = { + webp: "WebP", + jpeg: "JPEG", + avif: "AVIF", + png: "PNG", +}; + +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(1)} MB`; +} + +export function OptimizeForWebSettings() { + const { files, entries, selectedIndex } = useFileStore(); + const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = + useToolProcessor("optimize-for-web"); + + // Settings state + const [format, setFormat] = useState("webp"); + const [quality, setQuality] = useState(80); + const [maxWidth, setMaxWidth] = useState(""); + const [maxHeight, setMaxHeight] = useState(""); + const [stripMetadata, setStripMetadata] = useState(true); + const [showDimensions, setShowDimensions] = useState(false); + + // Preview state + const [preview, setPreview] = useState({ + loading: false, + previewUrl: null, + processedSize: null, + originalSize: null, + }); + const abortRef = useRef(null); + const debounceRef = useRef | null>(null); + const prevPreviewUrlRef = useRef(null); + + const hasFile = files.length > 0; + const currentEntry = entries[selectedIndex]; + + // Build settings object + const buildSettings = useCallback(() => { + const settings: Record = { + format, + quality, + progressive: true, + stripMetadata, + }; + const mw = Number(maxWidth); + const mh = Number(maxHeight); + if (mw > 0) settings.maxWidth = mw; + if (mh > 0) settings.maxHeight = mh; + return settings; + }, [format, quality, maxWidth, maxHeight, stripMetadata]); + + // Live preview - debounced request on parameter change + const fetchPreview = useCallback(() => { + if (!hasFile || !currentEntry) return; + + // Cancel any in-flight request + if (abortRef.current) abortRef.current.abort(); + + const controller = new AbortController(); + abortRef.current = controller; + setPreview((prev) => ({ ...prev, loading: true })); + + const formData = new FormData(); + formData.append("file", currentEntry.file); + formData.append("settings", JSON.stringify(buildSettings())); + + fetch("/api/v1/tools/optimize-for-web/preview", { + method: "POST", + headers: formatHeaders(), + body: formData, + signal: controller.signal, + }) + .then(async (response) => { + if (!response.ok) throw new Error(`Preview failed: ${response.status}`); + + const originalSize = Number(response.headers.get("X-Original-Size") ?? "0"); + const processedSize = Number(response.headers.get("X-Processed-Size") ?? "0"); + const blob = await response.blob(); + const previewUrl = URL.createObjectURL(blob); + + // Revoke previous preview URL + if (prevPreviewUrlRef.current) { + URL.revokeObjectURL(prevPreviewUrlRef.current); + } + prevPreviewUrlRef.current = previewUrl; + + // Write the preview into the file store so BeforeAfterSlider picks it up + useFileStore.getState().updateEntry(selectedIndex, { + processedUrl: previewUrl, + processedPreviewUrl: null, + processedFilename: null, + status: "completed", + originalSize, + processedSize, + }); + + setPreview({ + loading: false, + previewUrl, + processedSize, + originalSize, + }); + }) + .catch((err) => { + if (err instanceof Error && err.name === "AbortError") return; + setPreview((prev) => ({ ...prev, loading: false })); + }); + }, [hasFile, currentEntry, selectedIndex, buildSettings]); + + // Debounce preview on settings change + useEffect(() => { + if (!hasFile) return; + if (debounceRef.current) clearTimeout(debounceRef.current); + + const debounceMs = currentEntry && currentEntry.file.size > 20 * 1024 * 1024 ? 800 : 300; + debounceRef.current = setTimeout(fetchPreview, debounceMs); + + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [hasFile, currentEntry, fetchPreview]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (abortRef.current) abortRef.current.abort(); + if (debounceRef.current) clearTimeout(debounceRef.current); + if (prevPreviewUrlRef.current) URL.revokeObjectURL(prevPreviewUrlRef.current); + }; + }, []); + + // Final process handler (creates workspace + download link) + const handleProcess = () => { + const settings = buildSettings(); + if (files.length > 1) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (hasFile && !processing) handleProcess(); + }; + + const savings = + preview.originalSize && preview.processedSize + ? ((1 - preview.processedSize / preview.originalSize) * 100).toFixed(1) + : null; + + return ( +
+ {/* Format selector */} +
+

Output Format

+
+ {(["webp", "jpeg", "avif", "png"] as const).map((f) => ( + + ))} +
+
+ + {/* Quality slider - hidden for PNG */} + {format !== "png" && ( +
+
+ + {quality} +
+ setQuality(Number(e.target.value))} + className="w-full mt-1" + /> +
+ Smallest file + Best quality +
+
+ )} + + {/* Max dimensions - collapsible */} +
+ + {showDimensions && ( +
+
+ + setMaxWidth(e.target.value)} + min={1} + placeholder="px" + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+
+ + setMaxHeight(e.target.value)} + min={1} + placeholder="px" + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+
+ )} +
+ + {/* Strip metadata toggle */} +
+ + +
+ + {/* Size comparison card */} + {(preview.originalSize || preview.loading) && ( +
+
+ Size Comparison + {preview.loading && } +
+ {preview.originalSize != null && ( +
+ Original: {formatSize(preview.originalSize)} +
+ )} + {preview.processedSize != null && ( +
+ Optimized: {formatSize(preview.processedSize)} + + {FORMAT_LABELS[format]} + +
+ )} + {savings != null && ( +
0 ? "text-green-500" : "text-red-500" + }`} + > + {Number(savings) > 0 ? `${savings}% smaller` : `${Math.abs(Number(savings))}% larger`} +
+ )} +
+ )} + + {/* Error */} + {error &&

{error}

} + + {/* Process / Download */} + {processing ? ( + + ) : ( + + )} + + {downloadUrl && ( + + + Download + + )} + + ); +} diff --git a/apps/web/src/lib/tool-registry.tsx b/apps/web/src/lib/tool-registry.tsx index b1a5df53..4b2eff92 100644 --- a/apps/web/src/lib/tool-registry.tsx +++ b/apps/web/src/lib/tool-registry.tsx @@ -83,6 +83,11 @@ const ConvertSettings = lazy(() => const CompressSettings = lazy(() => import("@/components/tools/compress-settings").then((m) => ({ default: m.CompressSettings })), ); +const OptimizeForWebSettings = lazy(() => + import("@/components/tools/optimize-for-web-settings").then((m) => ({ + default: m.OptimizeForWebSettings, + })), +); const StripMetadataSettings = lazy(() => import("@/components/tools/strip-metadata-settings").then((m) => ({ default: m.StripMetadataSettings, @@ -396,6 +401,7 @@ export const toolRegistry = new Map([ ["bulk-rename", { displayMode: "before-after", Settings: BulkRenameSettings }], ["favicon", { displayMode: "before-after", Settings: FaviconSettings }], ["image-to-pdf", { displayMode: "before-after", Settings: ImageToPdfSettings }], + ["optimize-for-web", { displayMode: "before-after", Settings: OptimizeForWebSettings }], [ "pdf-to-image", { displayMode: "no-dropzone", Settings: PdfToImageSettings, ResultsPanel: PdfToImagePreview }, diff --git a/packages/image-engine/src/index.ts b/packages/image-engine/src/index.ts index d629c220..155e1dba 100644 --- a/packages/image-engine/src/index.ts +++ b/packages/image-engine/src/index.ts @@ -11,6 +11,7 @@ export { editMetadata } from "./operations/edit-metadata.js"; export { flip } from "./operations/flip.js"; export { grayscale } from "./operations/grayscale.js"; export { invert } from "./operations/invert.js"; +export { optimizeForWeb } from "./operations/optimize-for-web.js"; export { resize } from "./operations/resize.js"; export { rotate } from "./operations/rotate.js"; export { saturation } from "./operations/saturation.js"; diff --git a/packages/image-engine/src/operations/optimize-for-web.ts b/packages/image-engine/src/operations/optimize-for-web.ts new file mode 100644 index 00000000..998be208 --- /dev/null +++ b/packages/image-engine/src/operations/optimize-for-web.ts @@ -0,0 +1,43 @@ +import type { OptimizeForWebOptions, Sharp } from "../types.js"; + +export async function optimizeForWeb(image: Sharp, options: OptimizeForWebOptions): Promise { + const { + format, + quality, + maxWidth, + maxHeight, + progressive = true, + stripMetadata = true, + } = options; + + // Step 1: Resize if max dimensions are set + if (maxWidth || maxHeight) { + image = image.resize({ + width: maxWidth, + height: maxHeight, + fit: "inside", + withoutEnlargement: true, + }); + } + + // Step 2: Preserve metadata only if requested + // Sharp strips metadata by default on output, so we only need to act + // when the user wants to KEEP metadata. + if (!stripMetadata) { + image = image.withMetadata(); + } + + // Step 3: Convert to target format with optimized settings + switch (format) { + case "webp": + return image.webp({ quality, effort: 4 }); + case "jpeg": + return image.jpeg({ quality, progressive, mozjpeg: true }); + case "avif": + return image.avif({ quality, effort: 4 }); + case "png": + return image.png({ compressionLevel: 9, palette: true }); + default: + throw new Error(`Unsupported format: ${format}`); + } +} diff --git a/packages/image-engine/src/types.ts b/packages/image-engine/src/types.ts index 858a1ea2..8fea5749 100644 --- a/packages/image-engine/src/types.ts +++ b/packages/image-engine/src/types.ts @@ -172,3 +172,12 @@ export interface CorrectionParams { /** Denoise strength. 0 = off, 1-5 = median kernel size. */ denoise: number; } + +export interface OptimizeForWebOptions { + format: "webp" | "jpeg" | "avif" | "png"; + quality: number; + maxWidth?: number; + maxHeight?: number; + progressive?: boolean; + stripMetadata?: boolean; +} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 94242311..be41d601 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -55,6 +55,15 @@ export const TOOLS: Tool[] = [ route: "/compress", }, // Optimization + { + id: "optimize-for-web", + name: "Optimize for Web", + description: + "Optimize images for web with format conversion, quality control, and live preview", + category: "optimization", + icon: "Globe", + route: "/optimize-for-web", + }, { id: "strip-metadata", name: "Remove Metadata", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index a8a541d4..32844ca3 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -35,6 +35,11 @@ export const en = { rotate: { name: "Rotate & Flip", description: "Rotate, flip, and straighten images" }, convert: { name: "Convert", description: "Convert between image formats" }, compress: { name: "Compress", description: "Reduce file size by quality or target size" }, + "optimize-for-web": { + name: "Optimize for Web", + description: + "Optimize images for web with format conversion, quality control, and live preview", + }, "strip-metadata": { name: "Remove Metadata", description: "Remove EXIF, GPS, and camera info" }, "edit-metadata": { name: "Edit Metadata",