mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add replace-color tool and update tool page routing
- Add replace-color tool with pixel-level color replacement and tolerance - Register all 19 new Phase 3 tools in routes/tools/index.ts - Map all new tool IDs to settings components in tool-page.tsx - Add PDF, ZIP, ICO, JSON content types to download route - Install qrcode, jsqr, potrace, pdfkit dependencies
This commit is contained in:
@@ -24,7 +24,11 @@
|
||||
"dotenv": "^16.4.0",
|
||||
"drizzle-orm": "^0.38.0",
|
||||
"fastify": "^5.2.0",
|
||||
"jsqr": "^1.4.0",
|
||||
"p-queue": "^9.1.0",
|
||||
"pdfkit": "^0.18.0",
|
||||
"potrace": "^2.1.8",
|
||||
"qrcode": "^1.5.4",
|
||||
"sharp": "^0.33.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
@@ -32,6 +36,9 @@
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/better-sqlite3": "^7.6.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/pdfkit": "^0.17.5",
|
||||
"@types/potrace": "^2.1.5",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"drizzle-kit": "^0.30.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
|
||||
@@ -157,6 +157,10 @@ function getContentType(ext: string): string {
|
||||
tif: "image/tiff",
|
||||
avif: "image/avif",
|
||||
svg: "image/svg+xml",
|
||||
pdf: "application/pdf",
|
||||
zip: "application/zip",
|
||||
ico: "image/x-icon",
|
||||
json: "application/json",
|
||||
};
|
||||
return map[ext] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
@@ -6,12 +6,39 @@ import { registerConvert } from "./convert.js";
|
||||
import { registerCompress } from "./compress.js";
|
||||
import { registerStripMetadata } from "./strip-metadata.js";
|
||||
import { registerColorAdjustments } from "./color-adjustments.js";
|
||||
// Phase 3: Watermark & Overlay
|
||||
import { registerWatermarkText } from "./watermark-text.js";
|
||||
import { registerWatermarkImage } from "./watermark-image.js";
|
||||
import { registerTextOverlay } from "./text-overlay.js";
|
||||
import { registerCompose } from "./compose.js";
|
||||
// Phase 3: Utilities
|
||||
import { registerInfo } from "./info.js";
|
||||
import { registerCompare } from "./compare.js";
|
||||
import { registerFindDuplicates } from "./find-duplicates.js";
|
||||
import { registerColorPalette } from "./color-palette.js";
|
||||
import { registerQrGenerate } from "./qr-generate.js";
|
||||
import { registerBarcodeRead } from "./barcode-read.js";
|
||||
// Phase 3: Layout & Composition
|
||||
import { registerCollage } from "./collage.js";
|
||||
import { registerSplit } from "./split.js";
|
||||
import { registerBorder } from "./border.js";
|
||||
// Phase 3: Format & Conversion
|
||||
import { registerSvgToRaster } from "./svg-to-raster.js";
|
||||
import { registerVectorize } from "./vectorize.js";
|
||||
import { registerGifTools } from "./gif-tools.js";
|
||||
// Phase 3: Optimization extras
|
||||
import { registerBulkRename } from "./bulk-rename.js";
|
||||
import { registerFavicon } from "./favicon.js";
|
||||
import { registerImageToPdf } from "./image-to-pdf.js";
|
||||
// Phase 3: Adjustments extra
|
||||
import { registerReplaceColor } from "./replace-color.js";
|
||||
|
||||
/**
|
||||
* Registry that imports and registers all tool routes.
|
||||
* Each tool uses the createToolRoute factory from tool-factory.ts.
|
||||
*/
|
||||
export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
||||
// Phase 2: Core tools
|
||||
registerResize(app);
|
||||
registerCrop(app);
|
||||
registerRotate(app);
|
||||
@@ -20,5 +47,37 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
||||
registerStripMetadata(app);
|
||||
registerColorAdjustments(app);
|
||||
|
||||
app.log.info("Tool routes registered (7 tools, 10 endpoints)");
|
||||
// Phase 3: Watermark & Overlay
|
||||
registerWatermarkText(app);
|
||||
registerWatermarkImage(app);
|
||||
registerTextOverlay(app);
|
||||
registerCompose(app);
|
||||
|
||||
// Phase 3: Utilities
|
||||
registerInfo(app);
|
||||
registerCompare(app);
|
||||
registerFindDuplicates(app);
|
||||
registerColorPalette(app);
|
||||
registerQrGenerate(app);
|
||||
registerBarcodeRead(app);
|
||||
|
||||
// Phase 3: Layout & Composition
|
||||
registerCollage(app);
|
||||
registerSplit(app);
|
||||
registerBorder(app);
|
||||
|
||||
// Phase 3: Format & Conversion
|
||||
registerSvgToRaster(app);
|
||||
registerVectorize(app);
|
||||
registerGifTools(app);
|
||||
|
||||
// Phase 3: Optimization extras
|
||||
registerBulkRename(app);
|
||||
registerFavicon(app);
|
||||
registerImageToPdf(app);
|
||||
|
||||
// Phase 3: Adjustments extra
|
||||
registerReplaceColor(app);
|
||||
|
||||
app.log.info("Tool routes registered (26 tools, 29 endpoints)");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { z } from "zod";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
import sharp from "sharp";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
sourceColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#FF0000"),
|
||||
targetColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#00FF00"),
|
||||
makeTransparent: z.boolean().default(false),
|
||||
tolerance: z.number().min(0).max(255).default(30),
|
||||
});
|
||||
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
return {
|
||||
r: parseInt(hex.slice(1, 3), 16),
|
||||
g: parseInt(hex.slice(3, 5), 16),
|
||||
b: parseInt(hex.slice(5, 7), 16),
|
||||
};
|
||||
}
|
||||
|
||||
function colorDistance(
|
||||
r1: number, g1: number, b1: number,
|
||||
r2: number, g2: number, b2: number,
|
||||
): number {
|
||||
return Math.sqrt((r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2);
|
||||
}
|
||||
|
||||
export function registerReplaceColor(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "replace-color",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const source = hexToRgb(settings.sourceColor);
|
||||
const target = hexToRgb(settings.targetColor);
|
||||
|
||||
// Get raw RGBA pixels
|
||||
const image = sharp(inputBuffer).ensureAlpha();
|
||||
const { data, info } = await image.raw().toBuffer({ resolveWithObject: true });
|
||||
|
||||
const pixels = Buffer.from(data);
|
||||
const maxDist = settings.tolerance * 1.73; // sqrt(3) for RGB distance scaling
|
||||
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
const dist = colorDistance(
|
||||
pixels[i], pixels[i + 1], pixels[i + 2],
|
||||
source.r, source.g, source.b,
|
||||
);
|
||||
|
||||
if (dist <= maxDist) {
|
||||
if (settings.makeTransparent) {
|
||||
pixels[i + 3] = 0; // Make transparent
|
||||
} else {
|
||||
// Blend: closer to source color = more of target color
|
||||
const blend = 1 - dist / maxDist;
|
||||
pixels[i] = Math.round(pixels[i] * (1 - blend) + target.r * blend);
|
||||
pixels[i + 1] = Math.round(pixels[i + 1] * (1 - blend) + target.g * blend);
|
||||
pixels[i + 2] = Math.round(pixels[i + 2] * (1 - blend) + target.b * blend);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = await sharp(pixels, {
|
||||
raw: { width: info.width, height: info.height, channels: 4 },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
return { buffer, filename, contentType: "image/png" };
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user