From ba26ea4bc7cc1ae7c6218bf95dcb5c5a5a5701e1 Mon Sep 17 00:00:00 2001 From: Ashim Date: Tue, 21 Apr 2026 23:34:48 +0800 Subject: [PATCH] feat: add AVIF output format support across 6 remaining tools (#85) Closes #73 AVIF was already supported in the core engine, convert, compress, optimize-for-web, upscale, erase-object, svg-to-raster, and pdf-to-image tools. This adds AVIF as an output format option to the 6 tools that were missing it: split, collage, stitch, image-to-base64, noise-removal, and red-eye-removal. For each tool, both the frontend format selector (with quality slider for AVIF's lossy encoding) and the backend Zod schema + Sharp .avif() encoding were updated. AVIF defaults: quality from the user slider, effort 4 (balanced encode speed). Also fixes pre-existing Biome formatting violations in 5 files that were blocking a clean lint pass. --- apps/api/src/routes/tools/collage.ts | 6 +++++- apps/api/src/routes/tools/image-to-base64.ts | 6 +++++- apps/api/src/routes/tools/noise-removal.ts | 5 +++-- apps/api/src/routes/tools/split.ts | 13 ++++++++++--- apps/api/src/routes/tools/stitch.ts | 6 +++++- apps/web/src/components/tools/collage-settings.tsx | 1 + .../components/tools/image-to-base64-settings.tsx | 3 ++- .../src/components/tools/noise-removal-settings.tsx | 10 +++++----- .../components/tools/red-eye-removal-settings.tsx | 10 +++++----- apps/web/src/components/tools/split-settings.tsx | 3 ++- apps/web/src/components/tools/stitch-settings.tsx | 6 +++--- apps/web/src/stores/collage-store.ts | 2 +- apps/web/src/stores/split-store.ts | 4 ++-- 13 files changed, 49 insertions(+), 26 deletions(-) diff --git a/apps/api/src/routes/tools/collage.ts b/apps/api/src/routes/tools/collage.ts index 3105a307..ae4a2c9d 100644 --- a/apps/api/src/routes/tools/collage.ts +++ b/apps/api/src/routes/tools/collage.ts @@ -332,7 +332,7 @@ const settingsSchema = z.object({ cornerRadius: z.number().min(0).max(500).default(0), backgroundColor: z.string().default("#FFFFFF"), aspectRatio: z.string().default("free"), - outputFormat: z.enum(["png", "jpeg", "webp"]).default("png"), + outputFormat: z.enum(["png", "jpeg", "webp", "avif"]).default("png"), quality: z.number().min(1).max(100).default(90), }); @@ -633,6 +633,10 @@ export function registerCollage(app: FastifyInstance) { pipeline = pipeline.webp({ quality: settings.quality }); outputExt = "webp"; break; + case "avif": + pipeline = pipeline.avif({ quality: settings.quality, effort: 4 }); + outputExt = "avif"; + break; default: pipeline = pipeline.png(); outputExt = "png"; diff --git a/apps/api/src/routes/tools/image-to-base64.ts b/apps/api/src/routes/tools/image-to-base64.ts index a3d06856..bb8dc2ec 100644 --- a/apps/api/src/routes/tools/image-to-base64.ts +++ b/apps/api/src/routes/tools/image-to-base64.ts @@ -5,7 +5,7 @@ import { z } from "zod"; import { ensureSharpCompat } from "../../lib/heic-converter.js"; const settingsSchema = z.object({ - outputFormat: z.enum(["original", "jpeg", "png", "webp"]).default("original"), + outputFormat: z.enum(["original", "jpeg", "png", "webp", "avif"]).default("original"), quality: z.number().int().min(1).max(100).default(80), maxWidth: z.number().int().min(0).default(0), maxHeight: z.number().int().min(0).default(0), @@ -140,6 +140,10 @@ export function registerImageToBase64(app: FastifyInstance) { outputBuffer = await pipeline.webp({ quality: opts.quality }).toBuffer(); mimeType = "image/webp"; break; + case "avif": + outputBuffer = await pipeline.avif({ quality: opts.quality, effort: 4 }).toBuffer(); + mimeType = "image/avif"; + break; default: outputBuffer = await pipeline.toBuffer(); mimeType = detectMimeType(ext); diff --git a/apps/api/src/routes/tools/noise-removal.ts b/apps/api/src/routes/tools/noise-removal.ts index 11720ca6..e2deaca0 100644 --- a/apps/api/src/routes/tools/noise-removal.ts +++ b/apps/api/src/routes/tools/noise-removal.ts @@ -19,7 +19,7 @@ const settingsSchema = z.object({ strength: z.union([z.number(), z.string()]).transform(Number).default(50), detailPreservation: z.union([z.number(), z.string()]).transform(Number).default(50), colorNoise: z.union([z.number(), z.string()]).transform(Number).default(30), - format: z.enum(["original", "png", "jpeg", "webp"]).default("original"), + format: z.enum(["original", "png", "jpeg", "webp", "avif"]).default("original"), quality: z.union([z.number(), z.string()]).transform(Number).default(90), }); @@ -165,7 +165,7 @@ export function registerNoiseRemoval(app: FastifyInstance) { strength: z.union([z.number(), z.string()]).transform(Number).default(50), detailPreservation: z.union([z.number(), z.string()]).transform(Number).default(50), colorNoise: z.union([z.number(), z.string()]).transform(Number).default(30), - format: z.enum(["original", "png", "jpeg", "webp"]).default("original"), + format: z.enum(["original", "png", "jpeg", "webp", "avif"]).default("original"), quality: z.union([z.number(), z.string()]).transform(Number).default(90), }), process: async (inputBuffer, settings, filename) => { @@ -188,6 +188,7 @@ export function registerNoiseRemoval(app: FastifyInstance) { jpeg: "image/jpeg", jpg: "image/jpeg", webp: "image/webp", + avif: "image/avif", }; return { buffer: result.buffer, diff --git a/apps/api/src/routes/tools/split.ts b/apps/api/src/routes/tools/split.ts index b2da8801..78c80220 100644 --- a/apps/api/src/routes/tools/split.ts +++ b/apps/api/src/routes/tools/split.ts @@ -14,7 +14,7 @@ const settingsSchema = z.object({ rows: z.number().min(1).max(100).default(3), tileWidth: z.number().min(10).optional(), tileHeight: z.number().min(10).optional(), - outputFormat: z.enum(["original", "png", "jpg", "webp"]).default("original"), + outputFormat: z.enum(["original", "png", "jpg", "webp", "avif"]).default("original"), quality: z.number().min(1).max(100).default(90), }); @@ -29,6 +29,7 @@ function resolveOutputFormat( png: { sharpFormat: "png", ext: ".png" }, jpg: { sharpFormat: "jpeg", ext: ".jpg" }, webp: { sharpFormat: "webp", ext: ".webp" }, + avif: { sharpFormat: "avif", ext: ".avif" }, }; return map[outputFormat] ?? { sharpFormat: null, ext: originalExt }; } @@ -137,9 +138,12 @@ export function registerSplit(app: FastifyInstance) { let pipeline = sharp(fileBuffer).extract({ left, top, width: w, height: h }); if (sharpFormat) { const formatOpts: Record = {}; - if (sharpFormat === "jpeg" || sharpFormat === "webp") { + if (sharpFormat === "jpeg" || sharpFormat === "webp" || sharpFormat === "avif") { formatOpts.quality = settings.quality; } + if (sharpFormat === "avif") { + formatOpts.effort = 4; + } pipeline = pipeline.toFormat(sharpFormat, formatOpts); } @@ -220,9 +224,12 @@ export function registerSplit(app: FastifyInstance) { let pipeline = sharp(inputBuffer).extract({ left, top, width: w, height: h }); if (sharpFormat) { const formatOpts: Record = {}; - if (sharpFormat === "jpeg" || sharpFormat === "webp") { + if (sharpFormat === "jpeg" || sharpFormat === "webp" || sharpFormat === "avif") { formatOpts.quality = settings.quality; } + if (sharpFormat === "avif") { + formatOpts.effort = 4; + } pipeline = pipeline.toFormat(sharpFormat, formatOpts); } diff --git a/apps/api/src/routes/tools/stitch.ts b/apps/api/src/routes/tools/stitch.ts index 302565f3..7512e74d 100644 --- a/apps/api/src/routes/tools/stitch.ts +++ b/apps/api/src/routes/tools/stitch.ts @@ -23,7 +23,7 @@ const settingsSchema = z.object({ .string() .regex(/^#[0-9a-fA-F]{6}$/) .default("#FFFFFF"), - format: z.enum(["png", "jpeg", "webp"]).default("png"), + format: z.enum(["png", "jpeg", "webp", "avif"]).default("png"), quality: z.number().min(1).max(100).default(90), }); @@ -200,6 +200,8 @@ export function registerStitch(app: FastifyInstance) { pipeline = pipeline.jpeg({ quality: settings.quality }); } else if (settings.format === "webp") { pipeline = pipeline.webp({ quality: settings.quality }); + } else if (settings.format === "avif") { + pipeline = pipeline.avif({ quality: settings.quality, effort: 4 }); } else { pipeline = pipeline.png(); } @@ -230,6 +232,8 @@ export function registerStitch(app: FastifyInstance) { .toBuffer(); } else if (settings.format === "webp") { result = await sharp(result).webp({ quality: settings.quality }).toBuffer(); + } else if (settings.format === "avif") { + result = await sharp(result).avif({ quality: settings.quality, effort: 4 }).toBuffer(); } } diff --git a/apps/web/src/components/tools/collage-settings.tsx b/apps/web/src/components/tools/collage-settings.tsx index bd05116d..508bf8f0 100644 --- a/apps/web/src/components/tools/collage-settings.tsx +++ b/apps/web/src/components/tools/collage-settings.tsx @@ -25,6 +25,7 @@ const OUTPUT_FORMATS: { value: OutputFormat; label: string }[] = [ { value: "png", label: "PNG" }, { value: "jpeg", label: "JPEG" }, { value: "webp", label: "WebP" }, + { value: "avif", label: "AVIF" }, ]; const BG_PRESETS = [ diff --git a/apps/web/src/components/tools/image-to-base64-settings.tsx b/apps/web/src/components/tools/image-to-base64-settings.tsx index efe52a7c..5edf3d55 100644 --- a/apps/web/src/components/tools/image-to-base64-settings.tsx +++ b/apps/web/src/components/tools/image-to-base64-settings.tsx @@ -9,6 +9,7 @@ const OUTPUT_FORMATS = [ { value: "jpeg", label: "JPEG" }, { value: "png", label: "PNG" }, { value: "webp", label: "WebP" }, + { value: "avif", label: "AVIF" }, ] as const; export function ImageToBase64Settings() { @@ -68,7 +69,7 @@ export function ImageToBase64Settings() { }; const hasFiles = files.length > 0; - const showQuality = outputFormat === "jpeg" || outputFormat === "webp"; + const showQuality = outputFormat === "jpeg" || outputFormat === "webp" || outputFormat === "avif"; return (
diff --git a/apps/web/src/components/tools/noise-removal-settings.tsx b/apps/web/src/components/tools/noise-removal-settings.tsx index e135f72b..00d2c716 100644 --- a/apps/web/src/components/tools/noise-removal-settings.tsx +++ b/apps/web/src/components/tools/noise-removal-settings.tsx @@ -13,7 +13,7 @@ const TIERS: { id: Tier; label: string; desc: string }[] = [ { id: "maximum", label: "Maximum", desc: "Best AI model, slowest" }, ]; -const LOSSY_FORMATS = new Set(["jpeg", "webp"]); +const LOSSY_FORMATS = new Set(["jpeg", "webp", "avif"]); export interface NoiseRemovalControlsProps { settings?: Record; @@ -28,7 +28,7 @@ export function NoiseRemovalControls({ const [strength, setStrength] = useState(50); const [detailPreservation, setDetailPreservation] = useState(50); const [colorNoise, setColorNoise] = useState(30); - const [outputFormat, setOutputFormat] = useState<"original" | "png" | "jpeg" | "webp">( + const [outputFormat, setOutputFormat] = useState<"original" | "png" | "jpeg" | "webp" | "avif">( "original", ); const [quality, setQuality] = useState(90); @@ -44,7 +44,7 @@ export function NoiseRemovalControls({ setDetailPreservation(Number(initialSettings.detailPreservation)); if (initialSettings.colorNoise != null) setColorNoise(Number(initialSettings.colorNoise)); if (initialSettings.format != null) - setOutputFormat(initialSettings.format as "original" | "png" | "jpeg" | "webp"); + setOutputFormat(initialSettings.format as "original" | "png" | "jpeg" | "webp" | "avif"); if (initialSettings.quality != null) setQuality(Number(initialSettings.quality)); }, [initialSettings]); @@ -164,8 +164,8 @@ export function NoiseRemovalControls({ {/* Output format */}

Output Format

-
- {(["original", "png", "jpeg", "webp"] as const).map((f) => ( +
+ {(["original", "png", "jpeg", "webp", "avif"] as const).map((f) => (
- {(format === "jpeg" || format === "webp") && ( + {(format === "jpeg" || format === "webp" || format === "avif") && (