feat: add Optimize for Web tool (#68)

* feat(image-engine): add OptimizeForWebOptions type

* feat(image-engine): add optimizeForWeb operation

* feat(shared): add optimize-for-web tool definition and i18n

* feat(api): add optimize-for-web route with preview endpoint

* feat(web): add optimize-for-web settings component with live preview

* feat(web): register optimize-for-web in tool registry

* fix(web): align toggle switch translate with codebase pattern

---------

Co-authored-by: stirling-image <stirling-image@users.noreply.github.com>
This commit is contained in:
stirling-image
2026-04-14 16:16:04 +08:00
committed by GitHub
co-authored by stirling-image
parent c2c104e887
commit 5be8be3dc3
9 changed files with 590 additions and 0 deletions
+2
View File
@@ -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<void> {
{ 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 },
@@ -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<string, string> = {
webp: "image/webp",
jpeg: "image/jpeg",
avif: "image/avif",
png: "image/png",
};
const FORMAT_EXTENSIONS: Record<string, string> = {
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<typeof settingsSchema>;
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,
});
}
@@ -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<WebFormat, string> = {
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<WebFormat>("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<PreviewState>({
loading: false,
previewUrl: null,
processedSize: null,
originalSize: null,
});
const abortRef = useRef<AbortController | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const prevPreviewUrlRef = useRef<string | null>(null);
const hasFile = files.length > 0;
const currentEntry = entries[selectedIndex];
// Build settings object
const buildSettings = useCallback(() => {
const settings: Record<string, unknown> = {
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 (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Format selector */}
<div>
<p className="text-sm font-medium text-muted-foreground">Output Format</p>
<div className="grid grid-cols-4 gap-1 mt-1">
{(["webp", "jpeg", "avif", "png"] as const).map((f) => (
<button
key={f}
type="button"
onClick={() => setFormat(f)}
className={`text-xs py-1.5 rounded font-medium transition-colors ${
format === f
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:text-foreground"
}`}
>
{FORMAT_LABELS[f]}
</button>
))}
</div>
</div>
{/* Quality slider - hidden for PNG */}
{format !== "png" && (
<div>
<div className="flex justify-between items-center">
<label htmlFor="web-quality" className="text-xs text-muted-foreground">
Quality
</label>
<span className="text-xs font-mono text-foreground">{quality}</span>
</div>
<input
id="web-quality"
type="range"
min={1}
max={100}
value={quality}
onChange={(e) => setQuality(Number(e.target.value))}
className="w-full mt-1"
/>
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
<span>Smallest file</span>
<span>Best quality</span>
</div>
</div>
)}
{/* Max dimensions - collapsible */}
<div>
<button
type="button"
onClick={() => setShowDimensions(!showDimensions)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground w-full"
>
{showDimensions ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
<span>Max Dimensions</span>
</button>
{showDimensions && (
<div className="grid grid-cols-2 gap-2 mt-2">
<div>
<label htmlFor="max-width" className="text-[10px] text-muted-foreground">
Max Width
</label>
<input
id="max-width"
type="number"
value={maxWidth}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="max-height" className="text-[10px] text-muted-foreground">
Max Height
</label>
<input
id="max-height"
type="number"
value={maxHeight}
onChange={(e) => 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"
/>
</div>
</div>
)}
</div>
{/* Strip metadata toggle */}
<div className="flex items-center justify-between">
<label htmlFor="strip-meta" className="text-xs text-muted-foreground">
Strip Metadata
</label>
<button
id="strip-meta"
type="button"
role="switch"
aria-checked={stripMetadata}
onClick={() => setStripMetadata(!stripMetadata)}
className={`relative inline-flex h-4 w-7 items-center rounded-full transition-colors ${
stripMetadata ? "bg-primary" : "bg-muted"
}`}
>
<span
className={`inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform ${
stripMetadata ? "translate-x-3.5" : "translate-x-0.5"
}`}
/>
</button>
</div>
{/* Size comparison card */}
{(preview.originalSize || preview.loading) && (
<div className="rounded-lg border border-border bg-muted/50 p-3 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Size Comparison</span>
{preview.loading && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
</div>
{preview.originalSize != null && (
<div className="text-xs text-muted-foreground">
Original: {formatSize(preview.originalSize)}
</div>
)}
{preview.processedSize != null && (
<div className="text-xs text-muted-foreground">
Optimized: {formatSize(preview.processedSize)}
<span className="ml-1 font-medium uppercase text-[10px]">
{FORMAT_LABELS[format]}
</span>
</div>
)}
{savings != null && (
<div
className={`text-sm font-semibold ${
Number(savings) > 0 ? "text-green-500" : "text-red-500"
}`}
>
{Number(savings) > 0 ? `${savings}% smaller` : `${Math.abs(Number(savings))}% larger`}
</div>
)}
</div>
)}
{/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>}
{/* Process / Download */}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Optimizing"
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="submit"
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Process & Download (${files.length} files)` : "Process & Download"}
</button>
)}
{downloadUrl && (
<a
href={downloadUrl}
download
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
</a>
)}
</form>
);
}
+6
View File
@@ -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<string, ToolRegistryEntry>([
["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 },
+1
View File
@@ -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";
@@ -0,0 +1,43 @@
import type { OptimizeForWebOptions, Sharp } from "../types.js";
export async function optimizeForWeb(image: Sharp, options: OptimizeForWebOptions): Promise<Sharp> {
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}`);
}
}
+9
View File
@@ -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;
}
+9
View File
@@ -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",
+5
View File
@@ -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",