mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
co-authored by
stirling-image
parent
c2c104e887
commit
5be8be3dc3
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user