mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(gif-tools): SOTA upgrade with 6 processing modes (#52)
* feat(find-duplicates): upgrade to 128-bit dHash with metadata and thumbnails * feat(find-duplicates): add custom-results display mode and duplicate store * feat(find-duplicates): add results overview grid and detail comparison view * feat(find-duplicates): overhaul settings with sensitivity presets and download actions * feat(find-duplicates): update i18n description * chore: replace jsqr with zxing-wasm for barcode reading * feat(barcode-read): rewrite backend with zxing-wasm for all barcode types * feat(barcode-read): rewrite frontend with multi-file, results table, progress, export - Multi-file sequential processing with per-file progress - Structured results table with type badges and copy per-result - Copy All and Export CSV functionality - Thorough scan toggle (maps to tryHarder in zxing-wasm) - Before/after view shows annotated image with bounding boxes - Updated tool description in constants and i18n * feat(stitch): update tool name and description for redesign * feat(stitch): add grid layout, alignment, border, radius, quality, and new resize modes * feat(stitch): redesign settings UI with grid, alignment, border, radius, quality * test(stitch): add stitch to e2e tool navigation suite * feat(vectorize): redesign with dual-engine backend and preset-driven UI - Backend: potrace for B&W, VTracer (@neplex/vectorizer) for full-color vectorization - Frontend: 5 presets (logo, illustration, photo, sketch, custom) - Settings: color precision, gradient step, detail, smoothing, corner threshold, invert - Updated OpenAPI spec and i18n description * feat(border): redesign with presets, shadow, padding color, swatches - Add 8 one-click presets (Clean White, Gallery Black, Shadow, Rounded, Polaroid, Vintage, Minimal, Cinematic) - Implement proper shadow rendering with blur, offset X/Y, color, opacity - Add padding color control (was hardcoded white) - Add color swatches for quick color selection - Wrap in form for Enter key submission - Add smart validation (requires at least one effect active) - Align frontend/backend slider ranges - Organize UI with sections and collapsible shadow toggle * feat(split): overhaul image splitting with live grid overlay and tile preview - Add interactive-split display mode with SplitCanvas component - Live SVG grid overlay on uploaded image showing split boundaries - Two split modes: Grid (NxM) and Tile Size (px dimensions) - 9 grid presets (2x1, 1x2, 2x2, 3x1, 1x3, 3x3, 2x3, 3x2, 4x4) - Output format selection (original/PNG/JPG/WebP) with quality slider - Post-split tile preview thumbnails with individual download - Download All as ZIP button - HEIC/HEIF preview with loading spinner - Backend: tile-size mode, output format conversion, quality control - Zustand store for split state management * feat(split): rewrite backend and frontend settings Backend: tile-size mode, output format conversion, quality control. Frontend: split modes, presets, format selector, tile preview grid. * feat(border): add live CSS preview and remove before/after slider - Add imageWrapperStyle prop to ImageViewer for live border preview - Add onImageStyle callback through tool-page to settings components - Change border displayMode to no-comparison (no slider) - BorderControls sends live CSS styles (border, padding, radius, shadow) - Preview updates instantly as user adjusts sliders or clicks presets * fix: repair i18n file corrupted by formatter during merge conflict resolution * feat(border): enable live CSS preview in right pane as settings change * fix(border): keep CSS preview visible after processing for WYSIWYG consistency * chore(gif-tools): scaffold for SOTA upgrade - Add animated GIF test fixture (3 frames, 100x100) - Update tool description to reflect new capabilities - Add fflate dependency to API for ZIP creation * feat(gif-tools): rewrite backend with 6 processing modes Modes: resize (with percentage), optimize (colors/dither/effort), speed (delay manipulation), reverse (frame reorder), extract (single/range/all with ZIP), rotate (90/180/270 + flip). Adds /api/v1/tools/gif-tools/info metadata endpoint. * test(gif-tools): add integration tests for all 6 modes Tests metadata endpoint, resize (pixel + percentage), optimize, speed, reverse, extract (single/range/all), and rotate (angle + flip). Fix animated.gif fixture to be a real 3-frame animation (was a single 100x300 frame). Fix reverse and rotate modes to process frames individually and reassemble via GIF binary concatenation, since Sharp 0.33.x loses page-height metadata when reconstructing from raw pixel data. * feat(gif-tools): rewrite frontend with tabbed 6-mode UI - useGifInfo hook for metadata (frame count, dimensions, duration) - Info bar showing GIF properties - 3x2 mode grid: Resize, Optimize, Speed, Reverse, Extract, Rotate - Animation modes disabled for static images - Loop control (infinite/once/custom) - Batch processing support * test(gif-tools): add to representative tools in e2e suite --------- Co-authored-by: Siddharth Kumar Sah <siddharth123sk@gmail.com>
This commit is contained in:
co-authored by
Siddharth Kumar Sah
parent
4e99150a08
commit
a1e11dff74
@@ -3,101 +3,150 @@ import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/);
|
||||
|
||||
const settingsSchema = z.object({
|
||||
borderWidth: z.number().min(0).max(200).default(10),
|
||||
borderColor: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/)
|
||||
.default("#000000"),
|
||||
cornerRadius: z.number().min(0).max(500).default(0),
|
||||
borderColor: hexColor.default("#000000"),
|
||||
padding: z.number().min(0).max(200).default(0),
|
||||
shadowBlur: z.number().min(0).max(50).default(0),
|
||||
shadowColor: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6,8}$/)
|
||||
.default("#00000080"),
|
||||
paddingColor: hexColor.default("#FFFFFF"),
|
||||
cornerRadius: z.number().min(0).max(500).default(0),
|
||||
shadow: z.boolean().default(false),
|
||||
shadowBlur: z.number().min(1).max(50).default(15),
|
||||
shadowOffsetX: z.number().min(-50).max(50).default(0),
|
||||
shadowOffsetY: z.number().min(-50).max(50).default(5),
|
||||
shadowColor: hexColor.default("#000000"),
|
||||
shadowOpacity: z.number().min(0).max(100).default(40),
|
||||
});
|
||||
|
||||
function parseHex(hex: string) {
|
||||
return {
|
||||
r: parseInt(hex.slice(1, 3), 16),
|
||||
g: parseInt(hex.slice(3, 5), 16),
|
||||
b: parseInt(hex.slice(5, 7), 16),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerBorder(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "border",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const image = sharp(inputBuffer);
|
||||
const meta = await image.metadata();
|
||||
const w = meta.width ?? 100;
|
||||
const h = meta.height ?? 100;
|
||||
let buf = inputBuffer;
|
||||
|
||||
// Parse border color
|
||||
const br = parseInt(settings.borderColor.slice(1, 3), 16);
|
||||
const bg = parseInt(settings.borderColor.slice(3, 5), 16);
|
||||
const bb = parseInt(settings.borderColor.slice(5, 7), 16);
|
||||
// 1. Add padding
|
||||
if (settings.padding > 0) {
|
||||
const c = parseHex(settings.paddingColor);
|
||||
buf = await sharp(buf)
|
||||
.extend({
|
||||
top: settings.padding,
|
||||
bottom: settings.padding,
|
||||
left: settings.padding,
|
||||
right: settings.padding,
|
||||
background: { r: c.r, g: c.g, b: c.b, alpha: 1 },
|
||||
})
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
const totalBorder = settings.borderWidth + settings.padding;
|
||||
const shadowPad = settings.shadowBlur > 0 ? settings.shadowBlur * 2 : 0;
|
||||
// 2. Add border
|
||||
if (settings.borderWidth > 0) {
|
||||
const c = parseHex(settings.borderColor);
|
||||
buf = await sharp(buf)
|
||||
.extend({
|
||||
top: settings.borderWidth,
|
||||
bottom: settings.borderWidth,
|
||||
left: settings.borderWidth,
|
||||
right: settings.borderWidth,
|
||||
background: { r: c.r, g: c.g, b: c.b, alpha: 1 },
|
||||
})
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
// Extend image with border
|
||||
let result = sharp(inputBuffer).extend({
|
||||
top: totalBorder + shadowPad,
|
||||
bottom: totalBorder + shadowPad,
|
||||
left: totalBorder + shadowPad,
|
||||
right: totalBorder + shadowPad,
|
||||
background: { r: br, g: bg, b: bb, alpha: 1 },
|
||||
});
|
||||
// 3. Apply corner radius
|
||||
if (settings.cornerRadius > 0) {
|
||||
buf = await sharp(buf).ensureAlpha().png().toBuffer();
|
||||
const meta = await sharp(buf).metadata();
|
||||
const w = meta.width ?? 100;
|
||||
const h = meta.height ?? 100;
|
||||
const r = Math.min(settings.cornerRadius, w / 2, h / 2);
|
||||
|
||||
// If inner padding, overlay a background-colored rectangle for padding area
|
||||
if (settings.padding > 0 && settings.borderWidth > 0) {
|
||||
const _outerW = w + totalBorder * 2 + shadowPad * 2;
|
||||
const _outerH = h + totalBorder * 2 + shadowPad * 2;
|
||||
const mask = Buffer.from(
|
||||
`<svg width="${w}" height="${h}"><rect x="0" y="0" width="${w}" height="${h}" rx="${r}" ry="${r}" fill="white"/></svg>`,
|
||||
);
|
||||
buf = await sharp(buf)
|
||||
.composite([{ input: await sharp(mask).resize(w, h).toBuffer(), blend: "dest-in" }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
// Create a white padding region behind the image
|
||||
const paddingRect = await sharp({
|
||||
// 4. Apply shadow
|
||||
if (settings.shadow) {
|
||||
buf = await sharp(buf).ensureAlpha().png().toBuffer();
|
||||
const meta = await sharp(buf).metadata();
|
||||
const bW = meta.width ?? 100;
|
||||
const bH = meta.height ?? 100;
|
||||
|
||||
const sc = parseHex(settings.shadowColor);
|
||||
const alpha = settings.shadowOpacity / 100;
|
||||
const blur = settings.shadowBlur;
|
||||
const spread = Math.ceil(blur * 2);
|
||||
const ox = settings.shadowOffsetX;
|
||||
const oy = settings.shadowOffsetY;
|
||||
|
||||
// Create shadow silhouette matching image shape (respects rounded corners)
|
||||
const shadowSilhouette = await sharp({
|
||||
create: {
|
||||
width: w + settings.padding * 2,
|
||||
height: h + settings.padding * 2,
|
||||
width: bW,
|
||||
height: bH,
|
||||
channels: 4,
|
||||
background: { r: 255, g: 255, b: 255, alpha: 1 },
|
||||
background: { r: sc.r, g: sc.g, b: sc.b, alpha },
|
||||
},
|
||||
})
|
||||
.composite([{ input: buf, blend: "dest-in" }])
|
||||
.extend({
|
||||
top: spread,
|
||||
bottom: spread,
|
||||
left: spread,
|
||||
right: spread,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
})
|
||||
.blur(Math.max(blur, 0.3))
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const currentBuf = await result.toBuffer();
|
||||
result = sharp(currentBuf).composite([
|
||||
{
|
||||
input: paddingRect,
|
||||
top: settings.borderWidth + shadowPad,
|
||||
left: settings.borderWidth + shadowPad,
|
||||
// Calculate canvas padding for shadow spread + offset
|
||||
const padL = Math.max(0, spread - ox);
|
||||
const padR = Math.max(0, spread + ox);
|
||||
const padT = Math.max(0, spread - oy);
|
||||
const padB = Math.max(0, spread + oy);
|
||||
|
||||
const canvasW = bW + padL + padR;
|
||||
const canvasH = bH + padT + padB;
|
||||
|
||||
const imgX = padL;
|
||||
const imgY = padT;
|
||||
const shadX = Math.max(0, imgX + ox - spread);
|
||||
const shadY = Math.max(0, imgY + oy - spread);
|
||||
|
||||
buf = await sharp({
|
||||
create: {
|
||||
width: canvasW,
|
||||
height: canvasH,
|
||||
channels: 4,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
},
|
||||
{
|
||||
input: inputBuffer,
|
||||
top: totalBorder + shadowPad,
|
||||
left: totalBorder + shadowPad,
|
||||
},
|
||||
]);
|
||||
})
|
||||
.composite([
|
||||
{ input: shadowSilhouette, left: shadX, top: shadY },
|
||||
{ input: buf, left: imgX, top: imgY },
|
||||
])
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
// Apply rounded corners via SVG mask
|
||||
if (settings.cornerRadius > 0) {
|
||||
const buf = await result.ensureAlpha().toBuffer();
|
||||
const bufMeta = await sharp(buf).metadata();
|
||||
const maskW = bufMeta.width ?? w;
|
||||
const maskH = bufMeta.height ?? h;
|
||||
const r = Math.min(settings.cornerRadius, maskW / 2, maskH / 2);
|
||||
|
||||
const roundedMask = Buffer.from(
|
||||
`<svg width="${maskW}" height="${maskH}">
|
||||
<rect x="0" y="0" width="${maskW}" height="${maskH}" rx="${r}" ry="${r}" fill="white"/>
|
||||
</svg>`,
|
||||
);
|
||||
|
||||
const maskBuffer = await sharp(roundedMask).resize(maskW, maskH).toBuffer();
|
||||
|
||||
result = sharp(buf).composite([{ input: maskBuffer, blend: "dest-in" }]);
|
||||
}
|
||||
|
||||
const buffer = await result.png().toBuffer();
|
||||
return { buffer, filename, contentType: "image/png" };
|
||||
const buffer = await sharp(buf).png().toBuffer();
|
||||
const outName = filename.replace(/\.[^.]+$/, ".png");
|
||||
return { buffer, filename: outName, contentType: "image/png" };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user