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:
stirling-image
2026-04-13 16:23:07 +08:00
committed by GitHub
co-authored by Siddharth Kumar Sah
parent 4e99150a08
commit a1e11dff74
65 changed files with 6293 additions and 774 deletions
+263
View File
@@ -0,0 +1,263 @@
import { create } from "zustand";
import { COLLAGE_TEMPLATES, getDefaultTemplate } from "@/lib/collage-templates";
export type AspectRatio = "free" | "1:1" | "4:3" | "3:2" | "16:9" | "9:16" | "4:5";
export type OutputFormat = "png" | "jpeg" | "webp";
export type Phase = "upload" | "editing" | "processing" | "result";
export interface CollageImage {
id: string;
file: File;
blobUrl: string;
}
export interface CellTransform {
panX: number; // percentage -100..100
panY: number; // percentage -100..100
zoom: number; // 1.0 to 3.0
}
interface CollageState {
// Images
images: CollageImage[];
// Layout
templateId: string;
cellAssignments: number[]; // maps cellIndex -> imageIndex (-1 if empty)
// Per-cell transforms
cellTransforms: Record<number, CellTransform>;
// Style
gap: number;
cornerRadius: number;
backgroundColor: string;
bgPreset: "white" | "black" | "transparent" | "custom";
// Canvas
aspectRatio: AspectRatio;
// Output
outputFormat: OutputFormat;
quality: number;
// UI state
selectedCell: number | null;
phase: Phase;
resultUrl: string | null;
resultSize: number | null;
originalSize: number | null;
error: string | null;
jobId: string | null;
// Actions
addImages: (files: File[]) => void;
removeImage: (index: number) => void;
clearImages: () => void;
setTemplateId: (id: string) => void;
setCellAssignment: (cellIndex: number, imageIndex: number) => void;
swapCells: (a: number, b: number) => void;
setCellTransform: (cellIndex: number, transform: Partial<CellTransform>) => void;
resetCellTransform: (cellIndex: number) => void;
setGap: (v: number) => void;
setCornerRadius: (v: number) => void;
setBackgroundColor: (color: string) => void;
setBgPreset: (preset: "white" | "black" | "transparent" | "custom") => void;
setAspectRatio: (v: AspectRatio) => void;
setOutputFormat: (v: OutputFormat) => void;
setQuality: (v: number) => void;
setSelectedCell: (v: number | null) => void;
setPhase: (v: Phase) => void;
setResult: (url: string, size: number, originalSize: number, jobId: string) => void;
setError: (e: string | null) => void;
reset: () => void;
}
const DEFAULT_TRANSFORM: CellTransform = { panX: 0, panY: 0, zoom: 1 };
let nextImageId = 0;
function buildDefaultAssignments(imageCount: number, cellCount: number): number[] {
const assignments: number[] = [];
for (let i = 0; i < cellCount; i++) {
assignments.push(i < imageCount ? i : -1);
}
return assignments;
}
export const useCollageStore = create<CollageState>((set, get) => ({
images: [],
templateId: "2-h-equal",
cellAssignments: [],
cellTransforms: {},
gap: 8,
cornerRadius: 0,
backgroundColor: "#FFFFFF",
bgPreset: "white",
aspectRatio: "free",
outputFormat: "png",
quality: 90,
selectedCell: null,
phase: "upload",
resultUrl: null,
resultSize: null,
originalSize: null,
error: null,
jobId: null,
addImages: (files) => {
const newImages: CollageImage[] = files.map((f) => ({
id: `img-${++nextImageId}`,
file: f,
blobUrl: URL.createObjectURL(f),
}));
const state = get();
const allImages = [...state.images, ...newImages];
const template = getDefaultTemplate(allImages.length);
const cellCount = template.cells.length;
const assignments = buildDefaultAssignments(allImages.length, cellCount);
set({
images: allImages,
templateId: template.id,
cellAssignments: assignments,
cellTransforms: {},
phase: "editing",
resultUrl: null,
resultSize: null,
error: null,
});
},
removeImage: (index) => {
const state = get();
const img = state.images[index];
if (img) URL.revokeObjectURL(img.blobUrl);
const newImages = state.images.filter((_, i) => i !== index);
if (newImages.length === 0) {
get().reset();
return;
}
const template = getDefaultTemplate(newImages.length);
const assignments = buildDefaultAssignments(newImages.length, template.cells.length);
set({
images: newImages,
templateId: template.id,
cellAssignments: assignments,
cellTransforms: {},
phase: "editing",
resultUrl: null,
});
},
clearImages: () => {
const state = get();
for (const img of state.images) URL.revokeObjectURL(img.blobUrl);
set({
images: [],
cellAssignments: [],
cellTransforms: {},
phase: "upload",
resultUrl: null,
resultSize: null,
error: null,
selectedCell: null,
});
},
setTemplateId: (id) => {
const state = get();
const template = COLLAGE_TEMPLATES.find((t) => t.id === id);
if (!template) return;
const assignments = buildDefaultAssignments(state.images.length, template.cells.length);
set({ templateId: id, cellAssignments: assignments, cellTransforms: {}, resultUrl: null });
},
setCellAssignment: (cellIndex, imageIndex) => {
const state = get();
const newAssignments = [...state.cellAssignments];
newAssignments[cellIndex] = imageIndex;
set({ cellAssignments: newAssignments, resultUrl: null });
},
swapCells: (a, b) => {
const state = get();
const newAssignments = [...state.cellAssignments];
const newTransforms = { ...state.cellTransforms };
// Swap assignments
[newAssignments[a], newAssignments[b]] = [newAssignments[b], newAssignments[a]];
// Swap transforms
const tmpT = newTransforms[a];
newTransforms[a] = newTransforms[b];
newTransforms[b] = tmpT;
set({ cellAssignments: newAssignments, cellTransforms: newTransforms, resultUrl: null });
},
setCellTransform: (cellIndex, transform) => {
const state = get();
const current = state.cellTransforms[cellIndex] ?? { ...DEFAULT_TRANSFORM };
set({
cellTransforms: {
...state.cellTransforms,
[cellIndex]: { ...current, ...transform },
},
resultUrl: null,
});
},
resetCellTransform: (cellIndex) => {
const state = get();
const newTransforms = { ...state.cellTransforms };
delete newTransforms[cellIndex];
set({ cellTransforms: newTransforms, resultUrl: null });
},
setGap: (v) => set({ gap: v, resultUrl: null }),
setCornerRadius: (v) => set({ cornerRadius: v, resultUrl: null }),
setBackgroundColor: (color) => set({ backgroundColor: color, resultUrl: null }),
setBgPreset: (preset) => {
const colors: Record<string, string> = {
white: "#FFFFFF",
black: "#000000",
transparent: "transparent",
};
if (preset === "custom") {
set({ bgPreset: preset });
} else {
set({ bgPreset: preset, backgroundColor: colors[preset], resultUrl: null });
}
},
setAspectRatio: (v) => set({ aspectRatio: v, resultUrl: null }),
setOutputFormat: (v) => set({ outputFormat: v, resultUrl: null }),
setQuality: (v) => set({ quality: v, resultUrl: null }),
setSelectedCell: (v) => set({ selectedCell: v }),
setPhase: (v) => set({ phase: v }),
setResult: (url, size, originalSize, jobId) =>
set({ resultUrl: url, resultSize: size, originalSize, jobId, phase: "result", error: null }),
setError: (e) => set({ error: e, phase: "editing" }),
reset: () => {
const state = get();
for (const img of state.images) URL.revokeObjectURL(img.blobUrl);
nextImageId = 0;
set({
images: [],
templateId: "2-h-equal",
cellAssignments: [],
cellTransforms: {},
gap: 8,
cornerRadius: 0,
backgroundColor: "#FFFFFF",
bgPreset: "white",
aspectRatio: "free",
outputFormat: "png",
quality: 90,
selectedCell: null,
phase: "upload",
resultUrl: null,
resultSize: null,
originalSize: null,
error: null,
jobId: null,
});
},
}));
+63
View File
@@ -0,0 +1,63 @@
import { create } from "zustand";
export interface DuplicateFileInfo {
filename: string;
similarity: number;
width: number;
height: number;
fileSize: number;
format: string;
isBest: boolean;
thumbnail: string | null;
}
export interface DuplicateGroup {
groupId: number;
files: DuplicateFileInfo[];
}
export interface DuplicateResult {
totalImages: number;
uniqueImages: number;
spaceSaveable: number;
duplicateGroups: DuplicateGroup[];
}
interface DuplicateState {
results: DuplicateResult | null;
scanning: boolean;
viewMode: "overview" | "detail";
selectedGroupIndex: number;
bestOverrides: Record<number, number>;
setResults: (r: DuplicateResult | null) => void;
setScanning: (v: boolean) => void;
setViewMode: (m: "overview" | "detail") => void;
setSelectedGroup: (i: number) => void;
overrideBest: (groupIndex: number, fileIndex: number) => void;
reset: () => void;
}
export const useDuplicateStore = create<DuplicateState>((set) => ({
results: null,
scanning: false,
viewMode: "overview",
selectedGroupIndex: 0,
bestOverrides: {},
setResults: (results) =>
set({ results, viewMode: "overview", selectedGroupIndex: 0, bestOverrides: {} }),
setScanning: (scanning) => set({ scanning }),
setViewMode: (viewMode) => set({ viewMode }),
setSelectedGroup: (selectedGroupIndex) => set({ selectedGroupIndex, viewMode: "detail" }),
overrideBest: (groupIndex, fileIndex) =>
set((s) => ({ bestOverrides: { ...s.bestOverrides, [groupIndex]: fileIndex } })),
reset: () =>
set({
results: null,
scanning: false,
viewMode: "overview",
selectedGroupIndex: 0,
bestOverrides: {},
}),
}));
+135
View File
@@ -0,0 +1,135 @@
import { create } from "zustand";
export type SplitMode = "grid" | "tile-size";
export interface TileInfo {
row: number;
col: number;
label: string;
width: number;
height: number;
blobUrl: string | null;
}
interface SplitState {
// Split configuration
mode: SplitMode;
columns: number;
rows: number;
tileWidth: number;
tileHeight: number;
outputFormat: "original" | "png" | "jpg" | "webp";
quality: number;
// Image dimensions (set when image loads in the canvas)
imageDimensions: { width: number; height: number } | null;
// Processing state
processing: boolean;
error: string | null;
tiles: TileInfo[];
zipBlobUrl: string | null;
// Actions
setMode: (mode: SplitMode) => void;
setColumns: (n: number) => void;
setRows: (n: number) => void;
setTileWidth: (n: number) => void;
setTileHeight: (n: number) => void;
setOutputFormat: (f: "original" | "png" | "jpg" | "webp") => void;
setQuality: (q: number) => void;
setImageDimensions: (d: { width: number; height: number } | null) => void;
setProcessing: (p: boolean) => void;
setError: (e: string | null) => void;
setTiles: (tiles: TileInfo[]) => void;
setZipBlobUrl: (url: string | null) => void;
applyPreset: (cols: number, rows: number) => void;
reset: () => void;
// Derived helpers (computed in selectors)
getEffectiveGrid: () => { columns: number; rows: number };
getTileCount: () => number;
getComputedTileDimensions: () => { width: number; height: number } | null;
}
export const useSplitStore = create<SplitState>((set, get) => ({
mode: "grid",
columns: 3,
rows: 3,
tileWidth: 200,
tileHeight: 200,
outputFormat: "original",
quality: 90,
imageDimensions: null,
processing: false,
error: null,
tiles: [],
zipBlobUrl: null,
setMode: (mode) => set({ mode, tiles: [], zipBlobUrl: null, error: null }),
setColumns: (columns) =>
set({ columns: Math.max(1, Math.min(20, columns)), tiles: [], zipBlobUrl: null }),
setRows: (rows) => set({ rows: Math.max(1, Math.min(20, rows)), tiles: [], zipBlobUrl: null }),
setTileWidth: (tileWidth) =>
set({ tileWidth: Math.max(10, tileWidth), tiles: [], zipBlobUrl: null }),
setTileHeight: (tileHeight) =>
set({ tileHeight: Math.max(10, tileHeight), tiles: [], zipBlobUrl: null }),
setOutputFormat: (outputFormat) => set({ outputFormat }),
setQuality: (quality) => set({ quality }),
setImageDimensions: (imageDimensions) => set({ imageDimensions }),
setProcessing: (processing) => set({ processing }),
setError: (error) => set({ error }),
setTiles: (tiles) => set({ tiles }),
setZipBlobUrl: (url) => {
const prev = get().zipBlobUrl;
if (prev) URL.revokeObjectURL(prev);
set({ zipBlobUrl: url });
},
applyPreset: (cols, rows) =>
set({ mode: "grid", columns: cols, rows, tiles: [], zipBlobUrl: null, error: null }),
reset: () => {
const prev = get();
if (prev.zipBlobUrl) URL.revokeObjectURL(prev.zipBlobUrl);
for (const t of prev.tiles) {
if (t.blobUrl) URL.revokeObjectURL(t.blobUrl);
}
set({
tiles: [],
zipBlobUrl: null,
processing: false,
error: null,
imageDimensions: null,
});
},
getEffectiveGrid: () => {
const { mode, columns, rows, tileWidth, tileHeight, imageDimensions } = get();
if (mode === "tile-size" && imageDimensions) {
return {
columns: Math.max(1, Math.ceil(imageDimensions.width / tileWidth)),
rows: Math.max(1, Math.ceil(imageDimensions.height / tileHeight)),
};
}
return { columns, rows };
},
getTileCount: () => {
const { columns, rows } = get().getEffectiveGrid();
return columns * rows;
},
getComputedTileDimensions: () => {
const { imageDimensions, mode, tileWidth, tileHeight } = get();
if (!imageDimensions) return null;
const { columns, rows } = get().getEffectiveGrid();
if (mode === "tile-size") {
return { width: tileWidth, height: tileHeight };
}
return {
width: Math.floor(imageDimensions.width / columns),
height: Math.floor(imageDimensions.height / rows),
};
},
}));