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