From 01cbb16cd9effe164e54b8f32913a125ab1c5be3 Mon Sep 17 00:00:00 2001 From: stirling-image Date: Tue, 14 Apr 2026 10:17:19 +0800 Subject: [PATCH] feat: SOTA Image to Base64 converter with 6 output formats (#65) * feat(image-to-base64): register tool in shared constants and i18n * feat(image-to-base64): add API route with Sharp pipeline and base64 encoding * feat(image-to-base64): add Zustand store for base64 results * feat(image-to-base64): add settings panel component * feat(image-to-base64): add results panel with 6-tab output and batch accordion * feat(image-to-base64): register tool in frontend tool registry * fix(image-to-base64): pass through original buffer when no resize/conversion needed --------- Co-authored-by: stirling-image --- apps/api/src/routes/tools/image-to-base64.ts | 199 +++++++++++++ apps/api/src/routes/tools/index.ts | 2 + .../tools/image-to-base64-results.tsx | 271 ++++++++++++++++++ .../tools/image-to-base64-settings.tsx | 154 ++++++++++ apps/web/src/lib/tool-registry.tsx | 18 ++ apps/web/src/stores/base64-store.ts | 42 +++ packages/shared/src/constants.ts | 8 + packages/shared/src/i18n/en.ts | 4 + 8 files changed, 698 insertions(+) create mode 100644 apps/api/src/routes/tools/image-to-base64.ts create mode 100644 apps/web/src/components/tools/image-to-base64-results.tsx create mode 100644 apps/web/src/components/tools/image-to-base64-settings.tsx create mode 100644 apps/web/src/stores/base64-store.ts diff --git a/apps/api/src/routes/tools/image-to-base64.ts b/apps/api/src/routes/tools/image-to-base64.ts new file mode 100644 index 00000000..a3d06856 --- /dev/null +++ b/apps/api/src/routes/tools/image-to-base64.ts @@ -0,0 +1,199 @@ +import { basename } from "node:path"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import sharp from "sharp"; +import { z } from "zod"; +import { ensureSharpCompat } from "../../lib/heic-converter.js"; + +const settingsSchema = z.object({ + outputFormat: z.enum(["original", "jpeg", "png", "webp"]).default("original"), + quality: z.number().int().min(1).max(100).default(80), + maxWidth: z.number().int().min(0).default(0), + maxHeight: z.number().int().min(0).default(0), +}); + +interface FileResult { + filename: string; + mimeType: string; + width: number; + height: number; + originalSize: number; + encodedSize: number; + overheadPercent: number; + base64: string; + dataUri: string; +} + +interface FileError { + filename: string; + error: string; +} + +const MIME_MAP: Record = { + jpeg: "image/jpeg", + jpg: "image/jpeg", + png: "image/png", + webp: "image/webp", + gif: "image/gif", + svg: "image/svg+xml", + avif: "image/avif", + tiff: "image/tiff", + bmp: "image/bmp", + ico: "image/x-icon", + heic: "image/jpeg", + heif: "image/jpeg", +}; + +function detectMimeType(format: string): string { + return MIME_MAP[format.toLowerCase()] ?? "application/octet-stream"; +} + +export function registerImageToBase64(app: FastifyInstance) { + app.post( + "/api/v1/tools/image-to-base64", + async (request: FastifyRequest, reply: FastifyReply) => { + const files: Array<{ buffer: Buffer; filename: string }> = []; + let settings = {}; + + 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); + } + files.push({ + buffer: Buffer.concat(chunks), + filename: basename(part.filename ?? "image"), + }); + } else if (part.fieldname === "settings") { + try { + settings = JSON.parse(part.value as string); + } catch { + // ignore invalid JSON, use defaults + } + } + } + } 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" }); + } + + const parsed = settingsSchema.safeParse(settings); + if (!parsed.success) { + return reply.status(400).send({ + error: "Invalid settings", + details: parsed.error.flatten().fieldErrors, + }); + } + const opts = parsed.data; + + const results: FileResult[] = []; + const errors: FileError[] = []; + + for (const { buffer, filename } of files) { + try { + const originalSize = buffer.length; + + // Decode HEIC/HEIF to PNG for Sharp compatibility + const decoded = await ensureSharpCompat(buffer); + let pipeline = sharp(decoded); + + // Get original metadata for dimensions + const metadata = await pipeline.metadata(); + let width = metadata.width ?? 0; + let height = metadata.height ?? 0; + + // Apply resize if requested + if (opts.maxWidth > 0 || opts.maxHeight > 0) { + pipeline = pipeline.resize({ + width: opts.maxWidth > 0 ? opts.maxWidth : undefined, + height: opts.maxHeight > 0 ? opts.maxHeight : undefined, + fit: "inside", + withoutEnlargement: true, + }); + } + + // Determine output format and encode + let outputBuffer: Buffer; + let mimeType: string; + const ext = filename.split(".").pop()?.toLowerCase() ?? ""; + const isHeic = ["heic", "heif", "hif"].includes(ext); + + if (opts.outputFormat !== "original") { + switch (opts.outputFormat) { + case "jpeg": + outputBuffer = await pipeline.jpeg({ quality: opts.quality }).toBuffer(); + mimeType = "image/jpeg"; + break; + case "png": + outputBuffer = await pipeline.png().toBuffer(); + mimeType = "image/png"; + break; + case "webp": + outputBuffer = await pipeline.webp({ quality: opts.quality }).toBuffer(); + mimeType = "image/webp"; + break; + default: + outputBuffer = await pipeline.toBuffer(); + mimeType = detectMimeType(ext); + } + } else if (isHeic) { + outputBuffer = await pipeline.jpeg({ quality: opts.quality }).toBuffer(); + mimeType = "image/jpeg"; + } else if (ext === "svg" || ext === "svgz") { + outputBuffer = buffer; + mimeType = "image/svg+xml"; + } else if (opts.maxWidth > 0 || opts.maxHeight > 0) { + // Resize requested - must go through Sharp pipeline + outputBuffer = await pipeline.toBuffer(); + mimeType = detectMimeType(metadata.format ?? ext); + } else { + // No conversion, no resize - pass through decoded buffer as-is + outputBuffer = decoded; + mimeType = detectMimeType(metadata.format ?? ext); + } + + // Get final dimensions after resize + if (opts.maxWidth > 0 || opts.maxHeight > 0) { + const resizedMeta = await sharp(outputBuffer).metadata(); + width = resizedMeta.width ?? width; + height = resizedMeta.height ?? height; + } + + const base64 = outputBuffer.toString("base64"); + const encodedSize = Buffer.byteLength(base64, "utf8"); + const overheadPercent = + originalSize > 0 + ? Math.round(((encodedSize - originalSize) / originalSize) * 1000) / 10 + : 0; + + results.push({ + filename, + mimeType, + width, + height, + originalSize, + encodedSize, + overheadPercent, + base64, + dataUri: `data:${mimeType};base64,${base64}`, + }); + } catch (err) { + errors.push({ + filename, + error: err instanceof Error ? err.message : "Failed to process image", + }); + } + } + + return reply.send({ results, errors }); + }, + ); +} diff --git a/apps/api/src/routes/tools/index.ts b/apps/api/src/routes/tools/index.ts index f4de90da..75673a20 100644 --- a/apps/api/src/routes/tools/index.ts +++ b/apps/api/src/routes/tools/index.ts @@ -23,6 +23,7 @@ import { registerFavicon } from "./favicon.js"; import { registerFindDuplicates } from "./find-duplicates.js"; import { registerGifTools } from "./gif-tools.js"; import { registerImageEnhancement } from "./image-enhancement.js"; +import { registerImageToBase64 } from "./image-to-base64.js"; import { registerImageToPdf } from "./image-to-pdf.js"; import { registerInfo } from "./info.js"; import { registerNoiseRemoval } from "./noise-removal.js"; @@ -106,6 +107,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise { { id: "color-palette", register: registerColorPalette }, { id: "qr-generate", register: registerQrGenerate }, { id: "barcode-read", register: registerBarcodeRead }, + { id: "image-to-base64", register: registerImageToBase64 }, // Layout & Composition { id: "collage", register: registerCollage }, diff --git a/apps/web/src/components/tools/image-to-base64-results.tsx b/apps/web/src/components/tools/image-to-base64-results.tsx new file mode 100644 index 00000000..30c7b2a0 --- /dev/null +++ b/apps/web/src/components/tools/image-to-base64-results.tsx @@ -0,0 +1,271 @@ +import { Check, ChevronDown, ChevronRight, ClipboardCopy, Download, Loader2 } from "lucide-react"; +import { useCallback, useState } from "react"; +import type { Base64Result } from "@/stores/base64-store"; +import { useBase64Store } from "@/stores/base64-store"; + +// -- Snippet generators ----------------------------------------------------- + +type TabId = "datauri" | "raw" | "html" | "css" | "json" | "markdown"; + +interface Tab { + id: TabId; + label: string; + generate: (r: Base64Result) => string; +} + +const TABS: Tab[] = [ + { id: "datauri", label: "Data URI", generate: (r) => r.dataUri }, + { id: "raw", label: "Raw Base64", generate: (r) => r.base64 }, + { + id: "html", + label: "HTML", + generate: (r) => { + const alt = r.filename.replace(/\.[^.]+$/, ""); + return `${alt}`; + }, + }, + { + id: "css", + label: "CSS", + generate: (r) => `background-image: url(${r.dataUri});`, + }, + { + id: "json", + label: "JSON", + generate: (r) => JSON.stringify({ image: r.dataUri }, null, 2), + }, + { + id: "markdown", + label: "Markdown", + generate: (r) => { + const alt = r.filename.replace(/\.[^.]+$/, ""); + return `![${alt}](${r.dataUri})`; + }, + }, +]; + +// -- Helpers ---------------------------------------------------------------- + +function formatBytes(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`; +} + +// -- CopyButton ------------------------------------------------------------- + +function CopyButton({ text, label }: { text: string; label?: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [text]); + + return ( + + ); +} + +// -- Single file result ----------------------------------------------------- + +function FileResult({ result }: { result: Base64Result }) { + const [activeTab, setActiveTab] = useState("datauri"); + const tab = TABS.find((t) => t.id === activeTab)!; + const output = tab.generate(result); + + const handleDownload = useCallback(() => { + const blob = new Blob([output], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${result.filename}.base64.txt`; + a.click(); + URL.revokeObjectURL(url); + }, [output, result.filename]); + + return ( +
+ {/* Metadata */} +
+ {result.filename} +
+

{result.filename}

+

+ {result.width}x{result.height} · {formatBytes(result.originalSize)} →{" "} + {formatBytes(result.encodedSize)}{" "} + 50 ? "text-amber-500" : ""}> + (+{result.overheadPercent}%) + +

+
+
+ + {/* Tabs */} +
+ {TABS.map((t) => ( + + ))} +
+ + {/* Code output */} +
+
+          {output}
+        
+
+ + {/* Actions */} +
+ + +
+
+ ); +} + +// -- Batch accordion item --------------------------------------------------- + +function BatchItem({ + result, + expanded, + onToggle, +}: { + result: Base64Result; + expanded: boolean; + onToggle: () => void; +}) { + return ( +
+ + {expanded && ( +
+ +
+ )} +
+ ); +} + +// -- Main ResultsPanel ------------------------------------------------------ + +export function ImageToBase64Results() { + const { results, errors, processing, expandedIndex, setExpandedIndex } = useBase64Store(); + + if (processing) { + return ( +
+
+ +

Converting to base64...

+
+
+ ); + } + + if (results.length === 0) { + return ( +
+
+

+ Upload images and click "Convert to Base64" to get started. +

+
+
+ ); + } + + // Single file - show directly + if (results.length === 1 && errors.length === 0) { + return ( +
+ +
+ ); + } + + // Batch - accordion view + return ( +
+
+

+ {results.length} converted + {errors.length > 0 ? `, ${errors.length} failed` : ""} +

+ r.dataUri), + null, + 2, + )} + label="Copy All as JSON" + /> +
+ + {errors.map((err) => ( +
+

+ {err.filename}: {err.error} +

+
+ ))} + + {results.map((result, i) => ( + setExpandedIndex(expandedIndex === i ? -1 : i)} + /> + ))} +
+ ); +} diff --git a/apps/web/src/components/tools/image-to-base64-settings.tsx b/apps/web/src/components/tools/image-to-base64-settings.tsx new file mode 100644 index 00000000..7d45105f --- /dev/null +++ b/apps/web/src/components/tools/image-to-base64-settings.tsx @@ -0,0 +1,154 @@ +import { Loader2 } from "lucide-react"; +import { useState } from "react"; +import { formatHeaders } from "@/lib/api"; +import { useBase64Store } from "@/stores/base64-store"; +import { useFileStore } from "@/stores/file-store"; + +const OUTPUT_FORMATS = [ + { value: "original", label: "Keep Original" }, + { value: "jpeg", label: "JPEG" }, + { value: "png", label: "PNG" }, + { value: "webp", label: "WebP" }, +] as const; + +export function ImageToBase64Settings() { + const { files } = useFileStore(); + const { processing, setProcessing, setResults, reset } = useBase64Store(); + + const [outputFormat, setOutputFormat] = useState("original"); + const [quality, setQuality] = useState(80); + const [maxWidth, setMaxWidth] = useState(0); + const [maxHeight, setMaxHeight] = useState(0); + const [error, setError] = useState(null); + + const handleProcess = async () => { + if (files.length === 0) return; + + setProcessing(true); + setError(null); + reset(); + + try { + const formData = new FormData(); + for (const file of files) { + formData.append("files", file); + } + formData.append("settings", JSON.stringify({ outputFormat, quality, maxWidth, maxHeight })); + + const res = await fetch("/api/v1/tools/image-to-base64", { + method: "POST", + headers: formatHeaders(), + body: formData, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Failed: ${res.status}`); + } + + const data = await res.json(); + setResults(data.results, data.errors); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to convert"); + } finally { + setProcessing(false); + } + }; + + const hasFiles = files.length > 0; + const showQuality = outputFormat === "jpeg" || outputFormat === "webp"; + + return ( +
+ {/* Output Format */} +
+ +

+ Convert before encoding to control MIME type and size +

+
+ {OUTPUT_FORMATS.map((fmt) => ( + + ))} +
+
+ + {/* Quality slider */} + {showQuality && ( +
+
+ + {quality}% +
+ setQuality(Number(e.target.value))} + className="w-full mt-1 accent-primary" + /> +

+ Lower quality = smaller base64 string +

+
+ )} + + {/* Max Width */} +
+ + setMaxWidth(Math.max(0, Number(e.target.value)))} + placeholder="0 = no limit" + className="mt-1 w-full rounded bg-muted px-3 py-1.5 text-xs text-foreground placeholder:text-muted-foreground/50 outline-none" + /> +
+ + {/* Max Height */} +
+ + setMaxHeight(Math.max(0, Number(e.target.value)))} + placeholder="0 = no limit" + className="mt-1 w-full rounded bg-muted px-3 py-1.5 text-xs text-foreground placeholder:text-muted-foreground/50 outline-none" + /> +

+ Resize before encoding. Aspect ratio is preserved. 0 = no limit. +

+
+ + {/* Process button */} + + + {error &&

{error}

} +
+ ); +} diff --git a/apps/web/src/lib/tool-registry.tsx b/apps/web/src/lib/tool-registry.tsx index a8433057..f0602f6b 100644 --- a/apps/web/src/lib/tool-registry.tsx +++ b/apps/web/src/lib/tool-registry.tsx @@ -155,6 +155,16 @@ const BarcodeReadSettings = lazy(() => default: m.BarcodeReadSettings, })), ); +const ImageToBase64Settings = lazy(() => + import("@/components/tools/image-to-base64-settings").then((m) => ({ + default: m.ImageToBase64Settings, + })), +); +const ImageToBase64Results = lazy(() => + import("@/components/tools/image-to-base64-results").then((m) => ({ + default: m.ImageToBase64Results, + })), +); const CollageSettings = lazy(() => import("@/components/tools/collage-settings").then((m) => ({ default: m.CollageSettings })), ); @@ -354,6 +364,14 @@ export const toolRegistry = new Map([ { displayMode: "no-dropzone", Settings: QrGenerateSettings, ResultsPanel: QrGeneratePreview }, ], ["barcode-read", { displayMode: "before-after", Settings: BarcodeReadSettings }], + [ + "image-to-base64", + { + displayMode: "custom-results", + Settings: ImageToBase64Settings, + ResultsPanel: ImageToBase64Results, + }, + ], // Layout & Composition ["collage", { displayMode: "before-after", Settings: CollageSettings }], diff --git a/apps/web/src/stores/base64-store.ts b/apps/web/src/stores/base64-store.ts new file mode 100644 index 00000000..370da835 --- /dev/null +++ b/apps/web/src/stores/base64-store.ts @@ -0,0 +1,42 @@ +import { create } from "zustand"; + +export interface Base64Result { + filename: string; + mimeType: string; + width: number; + height: number; + originalSize: number; + encodedSize: number; + overheadPercent: number; + base64: string; + dataUri: string; +} + +export interface Base64Error { + filename: string; + error: string; +} + +interface Base64State { + results: Base64Result[]; + errors: Base64Error[]; + processing: boolean; + expandedIndex: number; + + setResults: (results: Base64Result[], errors: Base64Error[]) => void; + setProcessing: (v: boolean) => void; + setExpandedIndex: (i: number) => void; + reset: () => void; +} + +export const useBase64Store = create((set) => ({ + results: [], + errors: [], + processing: false, + expandedIndex: 0, + + setResults: (results, errors) => set({ results, errors, expandedIndex: 0 }), + setProcessing: (v) => set({ processing: v }), + setExpandedIndex: (i) => set({ expandedIndex: i }), + reset: () => set({ results: [], errors: [], processing: false, expandedIndex: 0 }), +})); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 980e2633..f230509d 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -307,6 +307,14 @@ export const TOOLS: Tool[] = [ icon: "ScanLine", route: "/barcode-read", }, + { + id: "image-to-base64", + name: "Image to Base64", + description: "Convert images to base64 strings for embedding in HTML, CSS, and more", + category: "utilities", + icon: "Code", + route: "/image-to-base64", + }, // Layout & Composition { id: "collage", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index f7649373..1f8bf5f2 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -152,6 +152,10 @@ export const en = { description: "Resize, optimize, change speed, reverse, extract frames, and rotate animated GIFs", }, + "image-to-base64": { + name: "Image to Base64", + description: "Convert images to base64 strings for embedding in HTML, CSS, and more", + }, pipeline: { name: "Pipeline Builder", description: "Chain multiple tools into a workflow" }, batch: { name: "Batch Processing", description: "Apply any tool to multiple images" }, },