mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add format tools (SVG-to-raster, vectorize, GIF) and optimization (rename, favicon, image-to-PDF)
Add 6 tools for format conversion and optimization extras: - svg-to-raster: SVG to PNG/JPG/WebP at custom resolution - vectorize: raster to SVG via potrace (B&W and color modes) - gif-tools: animated GIF resize, frame extraction, optimization - bulk-rename: pattern-based file renaming with ZIP output - favicon: generate all favicon/app icon sizes with manifest.json - image-to-pdf: combine images into PDF using pdfkit
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import { z } from "zod";
|
||||
import archiver from "archiver";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { basename, extname } from "node:path";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
pattern: z.string().min(1).max(200).default("image-{{index}}"),
|
||||
startIndex: z.number().min(0).default(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* Bulk rename files with a pattern and return as ZIP.
|
||||
* No image processing - just renames.
|
||||
*/
|
||||
export function registerBulkRename(app: FastifyInstance) {
|
||||
app.post(
|
||||
"/api/v1/tools/bulk-rename",
|
||||
async (request, reply) => {
|
||||
const files: Array<{ buffer: Buffer; filename: string }> = [];
|
||||
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 buf = Buffer.concat(chunks);
|
||||
if (buf.length > 0) {
|
||||
files.push({
|
||||
buffer: buf,
|
||||
filename: basename(part.filename ?? `file-${files.length}`),
|
||||
});
|
||||
}
|
||||
} 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 files provided" });
|
||||
}
|
||||
|
||||
let settings: z.infer<typeof settingsSchema>;
|
||||
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 });
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||
}
|
||||
|
||||
try {
|
||||
const jobId = randomUUID();
|
||||
|
||||
reply.raw.writeHead(200, {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": `attachment; filename="renamed-${jobId.slice(0, 8)}.zip"`,
|
||||
"Transfer-Encoding": "chunked",
|
||||
});
|
||||
|
||||
const archive = archiver("zip", { zlib: { level: 5 } });
|
||||
archive.pipe(reply.raw);
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const ext = extname(files[i].filename);
|
||||
const index = settings.startIndex + i;
|
||||
const padded = String(index).padStart(String(files.length + settings.startIndex).length, "0");
|
||||
const newName =
|
||||
settings.pattern
|
||||
.replace(/\{\{index\}\}/g, String(index))
|
||||
.replace(/\{\{padded\}\}/g, padded)
|
||||
.replace(/\{\{original\}\}/g, files[i].filename.replace(ext, "")) +
|
||||
ext;
|
||||
|
||||
archive.append(files[i].buffer, { name: newName });
|
||||
}
|
||||
|
||||
await archive.finalize();
|
||||
} catch (err) {
|
||||
if (!reply.raw.headersSent) {
|
||||
return reply.status(422).send({
|
||||
error: "Rename failed",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { z } from "zod";
|
||||
import sharp from "sharp";
|
||||
import archiver from "archiver";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { basename } from "node:path";
|
||||
|
||||
const FAVICON_SIZES = [
|
||||
{ name: "favicon-16x16.png", size: 16, format: "png" as const },
|
||||
{ name: "favicon-32x32.png", size: 32, format: "png" as const },
|
||||
{ name: "favicon-48x48.png", size: 48, format: "png" as const },
|
||||
{ name: "apple-touch-icon.png", size: 180, format: "png" as const },
|
||||
{ name: "android-chrome-192x192.png", size: 192, format: "png" as const },
|
||||
{ name: "android-chrome-512x512.png", size: 512, format: "png" as const },
|
||||
];
|
||||
|
||||
export function registerFavicon(app: FastifyInstance) {
|
||||
app.post(
|
||||
"/api/v1/tools/favicon",
|
||||
async (request, reply) => {
|
||||
let fileBuffer: Buffer | 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);
|
||||
}
|
||||
}
|
||||
} 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" });
|
||||
}
|
||||
|
||||
try {
|
||||
const jobId = randomUUID();
|
||||
|
||||
reply.raw.writeHead(200, {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": `attachment; filename="favicons-${jobId.slice(0, 8)}.zip"`,
|
||||
"Transfer-Encoding": "chunked",
|
||||
});
|
||||
|
||||
const archive = archiver("zip", { zlib: { level: 5 } });
|
||||
archive.pipe(reply.raw);
|
||||
|
||||
// Generate each size
|
||||
for (const icon of FAVICON_SIZES) {
|
||||
const buffer = await sharp(fileBuffer)
|
||||
.resize(icon.size, icon.size, { fit: "cover" })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
archive.append(buffer, { name: icon.name });
|
||||
}
|
||||
|
||||
// Generate ICO (use 16x16 and 32x32 PNGs embedded)
|
||||
// Simple ICO format: just include the 32x32 PNG as an ICO
|
||||
const ico32 = await sharp(fileBuffer)
|
||||
.resize(32, 32, { fit: "cover" })
|
||||
.png()
|
||||
.toBuffer();
|
||||
archive.append(ico32, { name: "favicon.ico" });
|
||||
|
||||
// Generate manifest.json (for PWA)
|
||||
const manifest = {
|
||||
name: "App",
|
||||
short_name: "App",
|
||||
icons: [
|
||||
{ src: "/android-chrome-192x192.png", sizes: "192x192", type: "image/png" },
|
||||
{ src: "/android-chrome-512x512.png", sizes: "512x512", type: "image/png" },
|
||||
],
|
||||
theme_color: "#ffffff",
|
||||
background_color: "#ffffff",
|
||||
display: "standalone",
|
||||
};
|
||||
archive.append(JSON.stringify(manifest, null, 2), { name: "manifest.json" });
|
||||
|
||||
// Generate HTML snippet
|
||||
const htmlSnippet = `<!-- Favicons -->
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="/favicon-48x48.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
`;
|
||||
archive.append(htmlSnippet, { name: "favicon-snippet.html" });
|
||||
|
||||
await archive.finalize();
|
||||
} catch (err) {
|
||||
if (!reply.raw.headersSent) {
|
||||
return reply.status(422).send({
|
||||
error: "Favicon generation failed",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { z } from "zod";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
import sharp from "sharp";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
width: z.number().min(1).max(4096).optional(),
|
||||
height: z.number().min(1).max(4096).optional(),
|
||||
extractFrame: z.number().min(0).optional(),
|
||||
optimize: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export function registerGifTools(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "gif-tools",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
if (settings.extractFrame !== undefined) {
|
||||
// Extract a single frame from animated GIF
|
||||
const image = sharp(inputBuffer, { page: settings.extractFrame });
|
||||
|
||||
if (settings.width || settings.height) {
|
||||
image.resize(settings.width, settings.height, { fit: "inside" });
|
||||
}
|
||||
|
||||
const buffer = await image.png().toBuffer();
|
||||
const outName = filename.replace(/\.gif$/i, "") + `_frame${settings.extractFrame}.png`;
|
||||
return { buffer, filename: outName, contentType: "image/png" };
|
||||
}
|
||||
|
||||
// Process animated GIF (preserve animation)
|
||||
const image = sharp(inputBuffer, { animated: true });
|
||||
|
||||
if (settings.width || settings.height) {
|
||||
image.resize(settings.width, settings.height, { fit: "inside" });
|
||||
}
|
||||
|
||||
if (settings.optimize) {
|
||||
// Reduce colors for optimization
|
||||
image.gif({ effort: 10 });
|
||||
}
|
||||
|
||||
const buffer = await image.gif().toBuffer();
|
||||
return { buffer, filename, contentType: "image/gif" };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { z } from "zod";
|
||||
import sharp from "sharp";
|
||||
import PDFDocument from "pdfkit";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join, basename } from "node:path";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
pageSize: z.enum(["A4", "Letter", "A3", "A5"]).default("A4"),
|
||||
orientation: z.enum(["portrait", "landscape"]).default("portrait"),
|
||||
margin: z.number().min(0).max(100).default(20),
|
||||
});
|
||||
|
||||
const PAGE_SIZES: Record<string, [number, number]> = {
|
||||
A4: [595.28, 841.89],
|
||||
Letter: [612, 792],
|
||||
A3: [841.89, 1190.55],
|
||||
A5: [419.53, 595.28],
|
||||
};
|
||||
|
||||
export function registerImageToPdf(app: FastifyInstance) {
|
||||
app.post(
|
||||
"/api/v1/tools/image-to-pdf",
|
||||
async (request, reply) => {
|
||||
const files: Array<{ buffer: Buffer; filename: string }> = [];
|
||||
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 buf = Buffer.concat(chunks);
|
||||
if (buf.length > 0) {
|
||||
files.push({
|
||||
buffer: buf,
|
||||
filename: basename(part.filename ?? `image-${files.length}`),
|
||||
});
|
||||
}
|
||||
} 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" });
|
||||
}
|
||||
|
||||
let settings: z.infer<typeof settingsSchema>;
|
||||
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 });
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||
}
|
||||
|
||||
try {
|
||||
let [pageW, pageH] = PAGE_SIZES[settings.pageSize] ?? PAGE_SIZES.A4;
|
||||
|
||||
if (settings.orientation === "landscape") {
|
||||
[pageW, pageH] = [pageH, pageW];
|
||||
}
|
||||
|
||||
const margin = settings.margin;
|
||||
const contentW = pageW - margin * 2;
|
||||
const contentH = pageH - margin * 2;
|
||||
|
||||
// Create PDF
|
||||
const doc = new PDFDocument({
|
||||
size: [pageW, pageH],
|
||||
margin,
|
||||
autoFirstPage: false,
|
||||
});
|
||||
|
||||
const pdfChunks: Buffer[] = [];
|
||||
doc.on("data", (chunk: Buffer) => pdfChunks.push(chunk));
|
||||
|
||||
const pdfDone = new Promise<Buffer>((resolve) => {
|
||||
doc.on("end", () => resolve(Buffer.concat(pdfChunks)));
|
||||
});
|
||||
|
||||
for (const file of files) {
|
||||
doc.addPage({ size: [pageW, pageH], margin });
|
||||
|
||||
// Convert to PNG for PDFKit compatibility
|
||||
const pngBuffer = await sharp(file.buffer)
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const meta = await sharp(pngBuffer).metadata();
|
||||
const imgW = meta.width ?? 100;
|
||||
const imgH = meta.height ?? 100;
|
||||
|
||||
// Scale to fit within content area
|
||||
const scale = Math.min(contentW / imgW, contentH / imgH, 1);
|
||||
const scaledW = imgW * scale;
|
||||
const scaledH = imgH * scale;
|
||||
|
||||
// Center on page
|
||||
const x = margin + (contentW - scaledW) / 2;
|
||||
const y = margin + (contentH - scaledH) / 2;
|
||||
|
||||
doc.image(pngBuffer, x, y, {
|
||||
width: scaledW,
|
||||
height: scaledH,
|
||||
});
|
||||
}
|
||||
|
||||
doc.end();
|
||||
const pdfBuffer = await pdfDone;
|
||||
|
||||
const jobId = randomUUID();
|
||||
const workspacePath = await createWorkspace(jobId);
|
||||
const filename = "images.pdf";
|
||||
const outputPath = join(workspacePath, "output", filename);
|
||||
await writeFile(outputPath, pdfBuffer);
|
||||
|
||||
return reply.send({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
|
||||
originalSize: files.reduce((s, f) => s + f.buffer.length, 0),
|
||||
processedSize: pdfBuffer.length,
|
||||
pages: files.length,
|
||||
});
|
||||
} catch (err) {
|
||||
return reply.status(422).send({
|
||||
error: "PDF creation failed",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { z } from "zod";
|
||||
import sharp from "sharp";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join, basename } from "node:path";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
width: z.number().min(1).max(8192).default(1024),
|
||||
height: z.number().min(1).max(8192).optional(),
|
||||
backgroundColor: z.string().regex(/^#[0-9a-fA-F]{6,8}$/).default("#00000000"),
|
||||
outputFormat: z.enum(["png", "jpg", "webp"]).default("png"),
|
||||
});
|
||||
|
||||
/**
|
||||
* SVG to raster conversion.
|
||||
* Custom route since input is SVG (not validated as image by magic bytes).
|
||||
*/
|
||||
export function registerSvgToRaster(app: FastifyInstance) {
|
||||
app.post(
|
||||
"/api/v1/tools/svg-to-raster",
|
||||
async (request, reply) => {
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let filename = "output";
|
||||
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 = basename(part.filename ?? "output").replace(/\.svg$/i, "");
|
||||
} 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 SVG file provided" });
|
||||
}
|
||||
|
||||
let settings: z.infer<typeof settingsSchema>;
|
||||
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 });
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||
}
|
||||
|
||||
try {
|
||||
let image = sharp(fileBuffer, { density: 300 }).resize(
|
||||
settings.width,
|
||||
settings.height ?? undefined,
|
||||
{ fit: "inside" },
|
||||
);
|
||||
|
||||
// Apply background if not transparent
|
||||
if (settings.backgroundColor !== "#00000000") {
|
||||
const bgR = parseInt(settings.backgroundColor.slice(1, 3), 16);
|
||||
const bgG = parseInt(settings.backgroundColor.slice(3, 5), 16);
|
||||
const bgB = parseInt(settings.backgroundColor.slice(5, 7), 16);
|
||||
image = image.flatten({ background: { r: bgR, g: bgG, b: bgB } });
|
||||
}
|
||||
|
||||
let buffer: Buffer;
|
||||
let ext: string;
|
||||
let contentType: string;
|
||||
|
||||
switch (settings.outputFormat) {
|
||||
case "jpg":
|
||||
buffer = await image.jpeg({ quality: 90 }).toBuffer();
|
||||
ext = "jpg";
|
||||
contentType = "image/jpeg";
|
||||
break;
|
||||
case "webp":
|
||||
buffer = await image.webp({ quality: 90 }).toBuffer();
|
||||
ext = "webp";
|
||||
contentType = "image/webp";
|
||||
break;
|
||||
case "png":
|
||||
default:
|
||||
buffer = await image.png().toBuffer();
|
||||
ext = "png";
|
||||
contentType = "image/png";
|
||||
break;
|
||||
}
|
||||
|
||||
const outFilename = `${filename}.${ext}`;
|
||||
const jobId = randomUUID();
|
||||
const workspacePath = await createWorkspace(jobId);
|
||||
const outputPath = join(workspacePath, "output", outFilename);
|
||||
await writeFile(outputPath, buffer);
|
||||
|
||||
return reply.send({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outFilename)}`,
|
||||
originalSize: fileBuffer.length,
|
||||
processedSize: buffer.length,
|
||||
});
|
||||
} catch (err) {
|
||||
return reply.status(422).send({
|
||||
error: "SVG conversion failed",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { z } from "zod";
|
||||
import sharp from "sharp";
|
||||
import potrace from "potrace";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join, basename } from "node:path";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
colorMode: z.enum(["bw", "color"]).default("bw"),
|
||||
threshold: z.number().min(0).max(255).default(128),
|
||||
detail: z.enum(["low", "medium", "high"]).default("medium"),
|
||||
});
|
||||
|
||||
function traceImage(
|
||||
buffer: Buffer,
|
||||
options: { threshold: number; turdSize: number; color?: string },
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
potrace.trace(buffer, options, (err: Error | null, svg: string) => {
|
||||
if (err) reject(err);
|
||||
else resolve(svg);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function posterize(
|
||||
buffer: Buffer,
|
||||
options: { steps: number; threshold: number },
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
potrace.posterize(buffer, options, (err: Error | null, svg: string) => {
|
||||
if (err) reject(err);
|
||||
else resolve(svg);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function registerVectorize(app: FastifyInstance) {
|
||||
app.post(
|
||||
"/api/v1/tools/vectorize",
|
||||
async (request, reply) => {
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let filename = "output";
|
||||
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 = basename(part.filename ?? "output").replace(/\.[^.]+$/, "");
|
||||
} 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" });
|
||||
}
|
||||
|
||||
let settings: z.infer<typeof settingsSchema>;
|
||||
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 });
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert to BMP-compatible format for potrace (PNG)
|
||||
const pngBuffer = await sharp(fileBuffer)
|
||||
.grayscale()
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const turdSize = settings.detail === "low" ? 10 : settings.detail === "high" ? 1 : 4;
|
||||
|
||||
let svg: string;
|
||||
|
||||
if (settings.colorMode === "color") {
|
||||
// Color mode: posterize
|
||||
svg = await posterize(pngBuffer, {
|
||||
steps: settings.detail === "low" ? 3 : settings.detail === "high" ? 8 : 5,
|
||||
threshold: settings.threshold,
|
||||
});
|
||||
} else {
|
||||
// B&W mode: simple trace
|
||||
svg = await traceImage(pngBuffer, {
|
||||
threshold: settings.threshold,
|
||||
turdSize,
|
||||
});
|
||||
}
|
||||
|
||||
const svgBuffer = Buffer.from(svg, "utf-8");
|
||||
const outFilename = `${filename}.svg`;
|
||||
const jobId = randomUUID();
|
||||
const workspacePath = await createWorkspace(jobId);
|
||||
const outputPath = join(workspacePath, "output", outFilename);
|
||||
await writeFile(outputPath, svgBuffer);
|
||||
|
||||
return reply.send({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outFilename)}`,
|
||||
originalSize: fileBuffer.length,
|
||||
processedSize: svgBuffer.length,
|
||||
svgPreview: svg.length < 50000 ? svg : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
return reply.status(422).send({
|
||||
error: "Vectorization failed",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
export function BulkRenameSettings() {
|
||||
const { files, processing, error, setProcessing, setError } = useFileStore();
|
||||
const [pattern, setPattern] = useState("image-{{index}}");
|
||||
const [startIndex, setStartIndex] = useState(1);
|
||||
const [downloadReady, setDownloadReady] = useState(false);
|
||||
|
||||
const handleProcess = async () => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
setProcessing(true);
|
||||
setError(null);
|
||||
setDownloadReady(false);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
for (const file of files) {
|
||||
formData.append("file", file);
|
||||
}
|
||||
formData.append("settings", JSON.stringify({ pattern, startIndex }));
|
||||
|
||||
const res = await fetch("/api/v1/tools/bulk-rename", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "renamed.zip";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setDownloadReady(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Rename failed");
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFiles = files.length > 0;
|
||||
|
||||
// Preview names
|
||||
const previewNames = hasFiles
|
||||
? files.slice(0, 5).map((f, i) => {
|
||||
const ext = f.name.includes(".") ? f.name.slice(f.name.lastIndexOf(".")) : "";
|
||||
const idx = startIndex + i;
|
||||
const padded = String(idx).padStart(String(files.length + startIndex).length, "0");
|
||||
return pattern
|
||||
.replace(/\{\{index\}\}/g, String(idx))
|
||||
.replace(/\{\{padded\}\}/g, padded)
|
||||
.replace(/\{\{original\}\}/g, f.name.replace(ext, "")) + ext;
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Pattern</label>
|
||||
<input
|
||||
type="text"
|
||||
value={pattern}
|
||||
onChange={(e) => setPattern(e.target.value)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||
Variables: {"{{index}}"}, {"{{padded}}"}, {"{{original}}"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Start Index</label>
|
||||
<input type="number" value={startIndex} onChange={(e) => setStartIndex(Number(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" />
|
||||
</div>
|
||||
|
||||
{previewNames.length > 0 && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Preview</label>
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{previewNames.map((name, i) => (
|
||||
<div key={i} className="text-xs font-mono text-foreground bg-muted px-2 py-0.5 rounded truncate">
|
||||
{name}
|
||||
</div>
|
||||
))}
|
||||
{files.length > 5 && (
|
||||
<p className="text-[10px] text-muted-foreground">... and {files.length - 5} more</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
<button
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFiles || processing || !pattern}
|
||||
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"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing ? "Renaming..." : `Rename ${files.length} Files`}
|
||||
</button>
|
||||
|
||||
{downloadReady && (
|
||||
<p className="text-xs text-green-600 flex items-center gap-1">
|
||||
<Download className="h-3 w-3" /> ZIP downloaded successfully
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
const SIZES = [
|
||||
{ name: "favicon-16x16.png", size: "16x16" },
|
||||
{ name: "favicon-32x32.png", size: "32x32" },
|
||||
{ name: "favicon-48x48.png", size: "48x48" },
|
||||
{ name: "apple-touch-icon.png", size: "180x180" },
|
||||
{ name: "android-chrome-192x192.png", size: "192x192" },
|
||||
{ name: "android-chrome-512x512.png", size: "512x512" },
|
||||
{ name: "favicon.ico", size: "32x32" },
|
||||
];
|
||||
|
||||
export function FaviconSettings() {
|
||||
const { files, processing, error, setProcessing, setError } = useFileStore();
|
||||
const [downloadReady, setDownloadReady] = useState(false);
|
||||
|
||||
const handleProcess = async () => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
setProcessing(true);
|
||||
setError(null);
|
||||
setDownloadReady(false);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", files[0]);
|
||||
|
||||
const res = await fetch("/api/v1/tools/favicon", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "favicons.zip";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setDownloadReady(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Generation failed");
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Upload a square image (recommended 512x512 or larger) to generate all
|
||||
favicon and app icon sizes.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Generated Sizes</label>
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{SIZES.map((s) => (
|
||||
<div key={s.name} className="flex justify-between text-xs text-foreground">
|
||||
<span className="font-mono">{s.name}</span>
|
||||
<span className="text-muted-foreground">{s.size}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
+ manifest.json + HTML snippet
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
<button
|
||||
onClick={handleProcess}
|
||||
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"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing ? "Generating..." : "Generate Favicons"}
|
||||
</button>
|
||||
|
||||
{downloadReady && (
|
||||
<p className="text-xs text-green-600 flex items-center gap-1">
|
||||
<Download className="h-3 w-3" /> ZIP downloaded successfully
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
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 GifToolsSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } =
|
||||
useToolProcessor("gif-tools");
|
||||
|
||||
const [mode, setMode] = useState<"resize" | "extract">("resize");
|
||||
const [width, setWidth] = useState("");
|
||||
const [height, setHeight] = useState("");
|
||||
const [extractFrame, setExtractFrame] = useState("0");
|
||||
const [optimize, setOptimize] = useState(false);
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings: Record<string, unknown> = {};
|
||||
if (mode === "extract") {
|
||||
settings.extractFrame = Number(extractFrame);
|
||||
} else {
|
||||
if (width) settings.width = Number(width);
|
||||
if (height) settings.height = Number(height);
|
||||
settings.optimize = optimize;
|
||||
}
|
||||
processFiles(files, settings);
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Mode</label>
|
||||
<div className="flex gap-1 mt-1">
|
||||
<button
|
||||
onClick={() => setMode("resize")}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${mode === "resize" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
Resize
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode("extract")}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${mode === "extract" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
Extract Frame
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === "resize" ? (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Width (px)</label>
|
||||
<input type="number" value={width} onChange={(e) => 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" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Height (px)</label>
|
||||
<input type="number" value={height} onChange={(e) => 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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input type="checkbox" checked={optimize} onChange={(e) => setOptimize(e.target.checked)} className="rounded" />
|
||||
Optimize file size
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Frame Number</label>
|
||||
<input type="number" value={extractFrame} onChange={(e) => setExtractFrame(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" />
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">Frame 0 is the first frame</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{originalSize != null && processedSize != null && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleProcess}
|
||||
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"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing ? "Processing..." : "Process GIF"}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
export function ImageToPdfSettings() {
|
||||
const { files, processing, error, setProcessing, setError } = useFileStore();
|
||||
const [pageSize, setPageSize] = useState<"A4" | "Letter" | "A3" | "A5">("A4");
|
||||
const [orientation, setOrientation] = useState<"portrait" | "landscape">("portrait");
|
||||
const [margin, setMargin] = useState(20);
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
|
||||
const handleProcess = async () => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
setProcessing(true);
|
||||
setError(null);
|
||||
setDownloadUrl(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
for (const file of files) {
|
||||
formData.append("file", file);
|
||||
}
|
||||
formData.append("settings", JSON.stringify({ pageSize, orientation, margin }));
|
||||
|
||||
const res = await fetch("/api/v1/tools/image-to-pdf", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
const result = await res.json();
|
||||
setDownloadUrl(result.downloadUrl);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "PDF creation failed");
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFiles = files.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{files.length} image{files.length !== 1 ? "s" : ""} will be combined
|
||||
into a PDF, one image per page.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Page Size</label>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => setPageSize(e.target.value as typeof pageSize)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="A4">A4</option>
|
||||
<option value="Letter">Letter</option>
|
||||
<option value="A3">A3</option>
|
||||
<option value="A5">A5</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Orientation</label>
|
||||
<div className="flex gap-1 mt-1">
|
||||
<button
|
||||
onClick={() => setOrientation("portrait")}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${orientation === "portrait" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
Portrait
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOrientation("landscape")}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${orientation === "landscape" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
Landscape
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Margin</label>
|
||||
<span className="text-xs font-mono text-foreground">{margin}pt</span>
|
||||
</div>
|
||||
<input type="range" min={0} max={100} value={margin} onChange={(e) => setMargin(Number(e.target.value))} className="w-full mt-1" />
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
<button
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFiles || 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"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing ? "Creating PDF..." : `Create PDF (${files.length} pages)`}
|
||||
</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 PDF
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
export function SvgToRasterSettings() {
|
||||
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } = useFileStore();
|
||||
const [width, setWidth] = useState(1024);
|
||||
const [height, setHeight] = useState("");
|
||||
const [backgroundColor, setBackgroundColor] = useState("#00000000");
|
||||
const [outputFormat, setOutputFormat] = useState<"png" | "jpg" | "webp">("png");
|
||||
const [transparent, setTransparent] = useState(true);
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
const [originalSize, setOriginalSize] = useState<number | null>(null);
|
||||
const [processedSize, setProcessedSize] = useState<number | null>(null);
|
||||
|
||||
const handleProcess = async () => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
setProcessing(true);
|
||||
setError(null);
|
||||
setDownloadUrl(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", files[0]);
|
||||
const settings: Record<string, unknown> = {
|
||||
width,
|
||||
outputFormat,
|
||||
backgroundColor: transparent ? "#00000000" : backgroundColor,
|
||||
};
|
||||
if (height) settings.height = Number(height);
|
||||
formData.append("settings", JSON.stringify(settings));
|
||||
|
||||
const res = await fetch("/api/v1/tools/svg-to-raster", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
const result = await res.json();
|
||||
setJobId(result.jobId);
|
||||
setProcessedUrl(result.downloadUrl);
|
||||
setDownloadUrl(result.downloadUrl);
|
||||
setOriginalSize(result.originalSize);
|
||||
setProcessedSize(result.processedSize);
|
||||
setSizes(result.originalSize, result.processedSize);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Conversion failed");
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Width (px)</label>
|
||||
<input type="number" value={width} onChange={(e) => setWidth(Number(e.target.value))} min={1} max={8192}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Height (px)</label>
|
||||
<input type="number" value={height} onChange={(e) => 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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Output Format</label>
|
||||
<select
|
||||
value={outputFormat}
|
||||
onChange={(e) => setOutputFormat(e.target.value as "png" | "jpg" | "webp")}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="png">PNG</option>
|
||||
<option value="jpg">JPEG</option>
|
||||
<option value="webp">WebP</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={transparent}
|
||||
onChange={(e) => setTransparent(e.target.checked)}
|
||||
disabled={outputFormat === "jpg"}
|
||||
className="rounded"
|
||||
/>
|
||||
Transparent background
|
||||
</label>
|
||||
|
||||
{!transparent && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Background Color</label>
|
||||
<input type="color" value={backgroundColor.slice(0, 7)} onChange={(e) => setBackgroundColor(e.target.value)} className="w-full mt-0.5 h-8 rounded border border-border" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{originalSize != null && processedSize != null && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<p>SVG: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||
<p>Output: {(processedSize / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleProcess}
|
||||
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"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing ? "Converting..." : "Convert SVG"}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
export function VectorizeSettings() {
|
||||
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } = useFileStore();
|
||||
const [colorMode, setColorMode] = useState<"bw" | "color">("bw");
|
||||
const [threshold, setThreshold] = useState(128);
|
||||
const [detail, setDetail] = useState<"low" | "medium" | "high">("medium");
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
const [originalSize, setOriginalSize] = useState<number | null>(null);
|
||||
const [processedSize, setProcessedSize] = useState<number | null>(null);
|
||||
|
||||
const handleProcess = async () => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
setProcessing(true);
|
||||
setError(null);
|
||||
setDownloadUrl(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", files[0]);
|
||||
formData.append("settings", JSON.stringify({ colorMode, threshold, detail }));
|
||||
|
||||
const res = await fetch("/api/v1/tools/vectorize", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
const result = await res.json();
|
||||
setJobId(result.jobId);
|
||||
setProcessedUrl(result.downloadUrl);
|
||||
setDownloadUrl(result.downloadUrl);
|
||||
setOriginalSize(result.originalSize);
|
||||
setProcessedSize(result.processedSize);
|
||||
setSizes(result.originalSize, result.processedSize);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Vectorization failed");
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Color Mode</label>
|
||||
<div className="flex gap-1 mt-1">
|
||||
<button
|
||||
onClick={() => setColorMode("bw")}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${colorMode === "bw" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
Black & White
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setColorMode("color")}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${colorMode === "color" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
Color
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Threshold</label>
|
||||
<span className="text-xs font-mono text-foreground">{threshold}</span>
|
||||
</div>
|
||||
<input type="range" min={0} max={255} value={threshold} onChange={(e) => setThreshold(Number(e.target.value))} className="w-full mt-1" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Detail Level</label>
|
||||
<select
|
||||
value={detail}
|
||||
onChange={(e) => setDetail(e.target.value as "low" | "medium" | "high")}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="low">Low (simpler, smaller SVG)</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High (detailed, larger SVG)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{originalSize != null && processedSize != null && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||
<p>SVG: {(processedSize / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleProcess}
|
||||
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"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing ? "Vectorizing..." : "Vectorize"}
|
||||
</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 SVG
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user