From 80961c0187e6e8a4b24b06b0f1b874b5307d30ac Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Fri, 8 May 2026 00:19:19 +0800 Subject: [PATCH] feat: add JXL to all tool format selectors, expand convert with BMP/ICO/JP2/QOI, add server-side editor export --- apps/api/src/routes/tools/collage.ts | 6 +- apps/api/src/routes/tools/convert.ts | 43 ++++++- apps/api/src/routes/tools/erase-object.ts | 8 +- apps/api/src/routes/tools/image-to-base64.ts | 6 +- apps/api/src/routes/tools/noise-removal.ts | 4 +- apps/api/src/routes/tools/optimize-for-web.ts | 4 +- apps/api/src/routes/tools/pdf-to-image.ts | 7 +- apps/api/src/routes/tools/split.ts | 3 +- apps/api/src/routes/tools/stitch.ts | 6 +- apps/api/src/routes/tools/svg-to-raster.ts | 6 +- apps/api/src/routes/tools/upscale.ts | 6 +- .../editor/common/export-dialog.tsx | 109 +++++++++++++++--- .../src/components/tools/collage-settings.tsx | 1 + .../src/components/tools/convert-settings.tsx | 36 +++++- .../tools/erase-object-settings.tsx | 14 ++- .../tools/image-to-base64-settings.tsx | 7 +- .../tools/noise-removal-settings.tsx | 16 +-- .../tools/optimize-for-web-settings.tsx | 7 +- .../tools/pdf-to-image-settings.tsx | 3 +- .../tools/red-eye-removal-settings.tsx | 16 +-- .../src/components/tools/split-settings.tsx | 3 +- .../src/components/tools/stitch-settings.tsx | 6 +- .../tools/svg-to-raster-settings.tsx | 6 +- .../src/components/tools/upscale-settings.tsx | 14 ++- apps/web/src/stores/collage-store.ts | 2 +- apps/web/src/stores/split-store.ts | 4 +- .../src/operations/optimize-for-web.ts | 2 + packages/image-engine/src/types.ts | 2 +- 28 files changed, 282 insertions(+), 65 deletions(-) diff --git a/apps/api/src/routes/tools/collage.ts b/apps/api/src/routes/tools/collage.ts index 74170b8a..00fad6a9 100644 --- a/apps/api/src/routes/tools/collage.ts +++ b/apps/api/src/routes/tools/collage.ts @@ -333,7 +333,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", "avif"]).default("png"), + outputFormat: z.enum(["png", "jpeg", "webp", "avif", "jxl"]).default("png"), quality: z.number().min(1).max(100).default(90), }); @@ -642,6 +642,10 @@ export function registerCollage(app: FastifyInstance) { pipeline = pipeline.avif({ quality: settings.quality, effort: 4 }); outputExt = "avif"; break; + case "jxl": + pipeline = pipeline.jxl({ quality: settings.quality }); + outputExt = "jxl"; + break; default: pipeline = pipeline.png(); outputExt = "png"; diff --git a/apps/api/src/routes/tools/convert.ts b/apps/api/src/routes/tools/convert.ts index d0dd7925..a4c401c8 100644 --- a/apps/api/src/routes/tools/convert.ts +++ b/apps/api/src/routes/tools/convert.ts @@ -3,6 +3,7 @@ import { convert } from "@snapotter/image-engine"; import type { FastifyInstance } from "fastify"; import sharp from "sharp"; import { z } from "zod"; +import { encodeBmp, encodeIco, encodeJp2, encodeQoi } from "../../lib/format-encoders.js"; import { encodeHeic } from "../../lib/heic-converter.js"; import { isSvgBuffer } from "../../lib/svg-sanitize.js"; import { createToolRoute } from "../tool-factory.js"; @@ -16,10 +17,36 @@ const FORMAT_CONTENT_TYPES: Record = { gif: "image/gif", heic: "image/heic", heif: "image/heif", + jxl: "image/jxl", + bmp: "image/bmp", + ico: "image/x-icon", + jp2: "image/jp2", + qoi: "image/x-qoi", +}; + +const CLI_ENCODERS: Record Promise> = { + bmp: encodeBmp, + ico: encodeIco, + jp2: encodeJp2, + qoi: encodeQoi, }; const settingsSchema = z.object({ - format: z.enum(["jpg", "png", "webp", "avif", "tiff", "gif", "heic", "heif"]), + format: z.enum([ + "jpg", + "png", + "webp", + "avif", + "tiff", + "gif", + "heic", + "heif", + "jxl", + "bmp", + "ico", + "jp2", + "qoi", + ]), quality: z.number().min(1).max(100).optional(), }); @@ -28,6 +55,20 @@ export function registerConvert(app: FastifyInstance) { toolId: "convert", settingsSchema, process: async (inputBuffer, settings, filename) => { + // CLI-encoded formats bypass Sharp entirely + const cliEncoder = CLI_ENCODERS[settings.format]; + if (cliEncoder) { + const outputBuffer = await cliEncoder(inputBuffer, settings.quality); + const ext = extname(filename); + const baseName = ext ? filename.slice(0, -ext.length) : filename; + const contentType = FORMAT_CONTENT_TYPES[settings.format] || "application/octet-stream"; + return { + buffer: outputBuffer, + filename: `${baseName}.${settings.format}`, + contentType, + }; + } + const sharpOpts = isSvgBuffer(inputBuffer) ? { density: 300 } : undefined; const image = sharp(inputBuffer, sharpOpts); diff --git a/apps/api/src/routes/tools/erase-object.ts b/apps/api/src/routes/tools/erase-object.ts index a5677001..9f6997c6 100644 --- a/apps/api/src/routes/tools/erase-object.ts +++ b/apps/api/src/routes/tools/erase-object.ts @@ -26,13 +26,14 @@ const EXT_MAP: Record = { avif: "avif", heic: "heic", heif: "heif", + jxl: "jxl", }; const BROWSER_PREVIEWABLE = new Set(["png", "jpg", "jpeg", "webp", "gif", "avif", "bmp"]); const settingsSchema = z.object({ format: z - .enum(["auto", "png", "jpg", "jpeg", "webp", "tiff", "gif", "avif", "heic", "heif"]) + .enum(["auto", "png", "jpg", "jpeg", "webp", "tiff", "gif", "avif", "heic", "heif", "jxl"]) .default("auto"), quality: z.number().int().min(1).max(100).default(95), }); @@ -203,7 +204,7 @@ export function registerEraseObject(app: FastifyInstance) { ); // Convert to the requested output format using Sharp - const needsNodeConversion = ["heic", "heif", "avif"].includes(format); + const needsNodeConversion = ["heic", "heif", "avif", "jxl"].includes(format); let outputBuffer: Buffer; let finalFormat = format; @@ -211,6 +212,9 @@ export function registerEraseObject(app: FastifyInstance) { if (format === "heic" || format === "heif") { outputBuffer = await encodeHeic(resultBuffer, quality); finalFormat = format; + } else if (format === "jxl") { + outputBuffer = await sharp(resultBuffer).jxl({ quality }).toBuffer(); + finalFormat = "jxl"; } else { outputBuffer = await sharp(resultBuffer).avif({ quality }).toBuffer(); finalFormat = "avif"; diff --git a/apps/api/src/routes/tools/image-to-base64.ts b/apps/api/src/routes/tools/image-to-base64.ts index c0a83dc1..a01b2a41 100644 --- a/apps/api/src/routes/tools/image-to-base64.ts +++ b/apps/api/src/routes/tools/image-to-base64.ts @@ -6,7 +6,7 @@ import { formatZodErrors } from "../../lib/errors.js"; import { ensureSharpCompat } from "../../lib/heic-converter.js"; const settingsSchema = z.object({ - outputFormat: z.enum(["original", "jpeg", "png", "webp", "avif"]).default("original"), + outputFormat: z.enum(["original", "jpeg", "png", "webp", "avif", "jxl"]).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), @@ -145,6 +145,10 @@ export function registerImageToBase64(app: FastifyInstance) { outputBuffer = await pipeline.avif({ quality: opts.quality, effort: 4 }).toBuffer(); mimeType = "image/avif"; break; + case "jxl": + outputBuffer = await pipeline.jxl({ quality: opts.quality }).toBuffer(); + mimeType = "image/jxl"; + 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 fc868d92..9071251e 100644 --- a/apps/api/src/routes/tools/noise-removal.ts +++ b/apps/api/src/routes/tools/noise-removal.ts @@ -21,7 +21,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", "avif"]).default("original"), + format: z.enum(["original", "png", "jpeg", "webp", "avif", "jxl"]).default("original"), quality: z.union([z.number(), z.string()]).transform(Number).default(90), }); @@ -198,7 +198,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", "avif"]).default("original"), + format: z.enum(["original", "png", "jpeg", "webp", "avif", "jxl"]).default("original"), quality: z.union([z.number(), z.string()]).transform(Number).default(90), }), process: async (inputBuffer, settings, filename) => { diff --git a/apps/api/src/routes/tools/optimize-for-web.ts b/apps/api/src/routes/tools/optimize-for-web.ts index 7c2df931..2f955bc5 100644 --- a/apps/api/src/routes/tools/optimize-for-web.ts +++ b/apps/api/src/routes/tools/optimize-for-web.ts @@ -17,6 +17,7 @@ const FORMAT_CONTENT_TYPES: Record = { jpeg: "image/jpeg", avif: "image/avif", png: "image/png", + jxl: "image/jxl", }; const FORMAT_EXTENSIONS: Record = { @@ -24,10 +25,11 @@ const FORMAT_EXTENSIONS: Record = { jpeg: "jpg", avif: "avif", png: "png", + jxl: "jxl", }; const settingsSchema = z.object({ - format: z.enum(["webp", "jpeg", "avif", "png"]).default("webp"), + format: z.enum(["webp", "jpeg", "avif", "png", "jxl"]).default("webp"), quality: z.number().min(1).max(100).default(80), maxWidth: z.number().positive().optional(), maxHeight: z.number().positive().optional(), diff --git a/apps/api/src/routes/tools/pdf-to-image.ts b/apps/api/src/routes/tools/pdf-to-image.ts index 43180b65..8e6fcd9d 100644 --- a/apps/api/src/routes/tools/pdf-to-image.ts +++ b/apps/api/src/routes/tools/pdf-to-image.ts @@ -14,7 +14,9 @@ import { createWorkspace } from "../../lib/workspace.js"; // ── Settings schema ────────────────────────────────────────────── const settingsSchema = z.object({ - format: z.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heic", "heif"]).default("png"), + format: z + .enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heic", "heif", "jxl"]) + .default("png"), dpi: z.number().min(36).max(2400).default(150), quality: z.number().min(1).max(100).default(85), colorMode: z.enum(["color", "grayscale", "bw"]).default("color"), @@ -86,6 +88,7 @@ const FORMAT_EXT: Record = { gif: ".gif", heic: ".heic", heif: ".heif", + jxl: ".jxl", }; async function convertWithSharp( @@ -114,6 +117,8 @@ async function convertWithSharp( return s.tiff().toBuffer(); case "gif": return s.gif().toBuffer(); + case "jxl": + return s.jxl({ quality }).toBuffer(); case "heic": case "heif": { const pngBuf = await s.png().toBuffer(); diff --git a/apps/api/src/routes/tools/split.ts b/apps/api/src/routes/tools/split.ts index a29053a6..6a062790 100644 --- a/apps/api/src/routes/tools/split.ts +++ b/apps/api/src/routes/tools/split.ts @@ -15,7 +15,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", "avif"]).default("original"), + outputFormat: z.enum(["original", "png", "jpg", "webp", "avif", "jxl"]).default("original"), quality: z.number().min(1).max(100).default(90), }); @@ -31,6 +31,7 @@ function resolveOutputFormat( jpg: { sharpFormat: "jpeg", ext: ".jpg" }, webp: { sharpFormat: "webp", ext: ".webp" }, avif: { sharpFormat: "avif", ext: ".avif" }, + jxl: { sharpFormat: "jxl", ext: ".jxl" }, }; return map[outputFormat] ?? { sharpFormat: null, ext: originalExt }; } diff --git a/apps/api/src/routes/tools/stitch.ts b/apps/api/src/routes/tools/stitch.ts index a5282945..fb7d189c 100644 --- a/apps/api/src/routes/tools/stitch.ts +++ b/apps/api/src/routes/tools/stitch.ts @@ -24,7 +24,7 @@ const settingsSchema = z.object({ .string() .regex(/^#[0-9a-fA-F]{6}$/) .default("#FFFFFF"), - format: z.enum(["png", "jpeg", "webp", "avif"]).default("png"), + format: z.enum(["png", "jpeg", "webp", "avif", "jxl"]).default("png"), quality: z.number().min(1).max(100).default(90), }); @@ -203,6 +203,8 @@ export function registerStitch(app: FastifyInstance) { pipeline = pipeline.webp({ quality: settings.quality }); } else if (settings.format === "avif") { pipeline = pipeline.avif({ quality: settings.quality, effort: 4 }); + } else if (settings.format === "jxl") { + pipeline = pipeline.jxl({ quality: settings.quality }); } else { pipeline = pipeline.png(); } @@ -235,6 +237,8 @@ export function registerStitch(app: FastifyInstance) { 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(); + } else if (settings.format === "jxl") { + result = await sharp(result).jxl({ quality: settings.quality }).toBuffer(); } } diff --git a/apps/api/src/routes/tools/svg-to-raster.ts b/apps/api/src/routes/tools/svg-to-raster.ts index 5761e560..9e340671 100644 --- a/apps/api/src/routes/tools/svg-to-raster.ts +++ b/apps/api/src/routes/tools/svg-to-raster.ts @@ -26,7 +26,7 @@ const settingsSchema = z.object({ .string() .regex(/^#[0-9a-fA-F]{6,8}$/) .default("#00000000"), - outputFormat: z.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heif"]).default("png"), + outputFormat: z.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heif", "jxl"]).default("png"), }); interface ParsedSvgFile { @@ -77,6 +77,10 @@ async function convertSvg( buffer = await image.gif().toBuffer(); ext = "gif"; break; + case "jxl": + buffer = await image.jxl({ quality: settings.quality }).toBuffer(); + ext = "jxl"; + break; case "heif": { const pngBuffer = await image.png().toBuffer(); buffer = await encodeHeic(pngBuffer, settings.quality); diff --git a/apps/api/src/routes/tools/upscale.ts b/apps/api/src/routes/tools/upscale.ts index 7468ef4b..a82798b9 100644 --- a/apps/api/src/routes/tools/upscale.ts +++ b/apps/api/src/routes/tools/upscale.ts @@ -158,7 +158,7 @@ export function registerUpscale(app: FastifyInstance) { // The result will be delivered via the SSE progress channel. reply.status(202).send({ jobId: progressJobId, async: true }); - const needsNodeConversion = ["heic", "heif", "avif"].includes(format); + const needsNodeConversion = ["heic", "heif", "avif", "jxl"].includes(format); const pythonFormat = needsNodeConversion ? "png" : format; const onProgress = (percent: number, stage: string) => { @@ -185,6 +185,9 @@ export function registerUpscale(app: FastifyInstance) { if (format === "heic" || format === "heif") { outputBuffer = await encodeHeic(result.buffer, outputQuality); finalFormat = format; + } else if (format === "jxl") { + outputBuffer = await sharp(result.buffer).jxl({ quality: outputQuality }).toBuffer(); + finalFormat = "jxl"; } else if (format === "avif") { outputBuffer = await sharp(result.buffer).avif({ quality: outputQuality }).toBuffer(); finalFormat = "avif"; @@ -201,6 +204,7 @@ export function registerUpscale(app: FastifyInstance) { avif: "avif", heic: "heic", heif: "heif", + jxl: "jxl", }; const ext = EXT_MAP[finalFormat] || "png"; const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_${scale}x.${ext}`; diff --git a/apps/web/src/components/editor/common/export-dialog.tsx b/apps/web/src/components/editor/common/export-dialog.tsx index 0e9323e4..690deb9c 100644 --- a/apps/web/src/components/editor/common/export-dialog.tsx +++ b/apps/web/src/components/editor/common/export-dialog.tsx @@ -23,7 +23,7 @@ import type { Guide, } from "@/types/editor"; -type ExportFormat = "png" | "jpeg" | "webp"; +type ExportFormat = "png" | "jpeg" | "webp" | "avif" | "tiff" | "gif" | "jxl"; interface ExportSettings { format: ExportFormat; @@ -34,21 +34,32 @@ interface ExportSettings { transparent: boolean; } -const FORMAT_OPTIONS: { value: ExportFormat; label: string; supportsTransparency: boolean }[] = [ - { value: "png", label: "PNG", supportsTransparency: true }, - { value: "jpeg", label: "JPEG", supportsTransparency: false }, - { value: "webp", label: "WebP", supportsTransparency: true }, +const FORMAT_OPTIONS: { + value: ExportFormat; + label: string; + supportsTransparency: boolean; + needsServerConvert: boolean; +}[] = [ + { value: "png", label: "PNG", supportsTransparency: true, needsServerConvert: false }, + { value: "jpeg", label: "JPEG", supportsTransparency: false, needsServerConvert: false }, + { value: "webp", label: "WebP", supportsTransparency: true, needsServerConvert: false }, + { value: "avif", label: "AVIF", supportsTransparency: true, needsServerConvert: true }, + { value: "tiff", label: "TIFF", supportsTransparency: true, needsServerConvert: true }, + { value: "gif", label: "GIF", supportsTransparency: true, needsServerConvert: true }, + { value: "jxl", label: "JXL", supportsTransparency: true, needsServerConvert: true }, ]; function getMimeType(format: ExportFormat): string { - switch (format) { - case "png": - return "image/png"; - case "jpeg": - return "image/jpeg"; - case "webp": - return "image/webp"; - } + const mimes: Record = { + png: "image/png", + jpeg: "image/jpeg", + webp: "image/webp", + avif: "image/avif", + tiff: "image/tiff", + gif: "image/gif", + jxl: "image/jxl", + }; + return mimes[format]; } export function ExportDialog({ onClose }: { onClose: () => void }) { @@ -77,9 +88,14 @@ export function ExportDialog({ onClose }: { onClose: () => void }) { const maxPreview = 200; const scale = Math.min(maxPreview / canvasSize.width, maxPreview / canvasSize.height); + // For server-convert formats the Canvas API cannot produce a preview, + // so fall back to PNG for the thumbnail. + const fmtOpt = FORMAT_OPTIONS.find((o) => o.value === settings.format); + const previewMime = fmtOpt?.needsServerConvert ? "image/png" : getMimeType(settings.format); + const url = stage.toDataURL({ pixelRatio: scale, - mimeType: getMimeType(settings.format), + mimeType: previewMime, quality: settings.quality / 100, x: 0, y: 0, @@ -134,6 +150,69 @@ export function ExportDialog({ onClose }: { onClose: () => void }) { if (!stage) return; const pixelRatio = settings.width / canvasSize.width; + + // Server-side convert for formats the Canvas API cannot produce + const formatOption = FORMAT_OPTIONS.find((o) => o.value === settings.format); + if (formatOption?.needsServerConvert) { + let stageCanvas: HTMLCanvasElement; + if (!settings.transparent || settings.format === "jpeg") { + const raw = stage.toCanvas({ + pixelRatio, + x: 0, + y: 0, + width: canvasSize.width, + height: canvasSize.height, + }); + const exportCanvas = document.createElement("canvas"); + exportCanvas.width = raw.width; + exportCanvas.height = raw.height; + const ctx = exportCanvas.getContext("2d"); + if (!ctx) return; + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height); + ctx.drawImage(raw, 0, 0); + stageCanvas = exportCanvas; + } else { + stageCanvas = stage.toCanvas({ + pixelRatio, + x: 0, + y: 0, + width: canvasSize.width, + height: canvasSize.height, + }); + } + + stageCanvas.toBlob(async (blob) => { + if (!blob) return; + const formData = new FormData(); + formData.append("file", blob, "export.png"); + formData.append( + "settings", + JSON.stringify({ format: settings.format, quality: settings.quality }), + ); + try { + const res = await fetch("/api/v1/tools/convert", { + method: "POST", + body: formData, + }); + if (!res.ok) throw new Error("Server convert failed"); + const json = await res.json(); + if (json.downloadUrl) { + const a = document.createElement("a"); + a.href = json.downloadUrl; + a.download = `export.${settings.format}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + markClean(); + } + } catch (err) { + console.error("Server-side export failed:", err); + } + }, "image/png"); + return; + } + let dataUrl: string; if (!settings.transparent || settings.format === "jpeg") { @@ -306,7 +385,7 @@ export function ExportDialog({ onClose }: { onClose: () => void }) { [onClose], ); - const supportsQuality = settings.format === "jpeg" || settings.format === "webp"; + const supportsQuality = settings.format !== "png"; const supportsTransparency = settings.format !== "jpeg"; return ( diff --git a/apps/web/src/components/tools/collage-settings.tsx b/apps/web/src/components/tools/collage-settings.tsx index 508bf8f0..adba888e 100644 --- a/apps/web/src/components/tools/collage-settings.tsx +++ b/apps/web/src/components/tools/collage-settings.tsx @@ -26,6 +26,7 @@ const OUTPUT_FORMATS: { value: OutputFormat; label: string }[] = [ { value: "jpeg", label: "JPEG" }, { value: "webp", label: "WebP" }, { value: "avif", label: "AVIF" }, + { value: "jxl", label: "JXL" }, ]; const BG_PRESETS = [ diff --git a/apps/web/src/components/tools/convert-settings.tsx b/apps/web/src/components/tools/convert-settings.tsx index 849748ae..dcb2f0a4 100644 --- a/apps/web/src/components/tools/convert-settings.tsx +++ b/apps/web/src/components/tools/convert-settings.tsx @@ -4,8 +4,38 @@ import { ProgressCard } from "@/components/common/progress-card"; import { useToolProcessor } from "@/hooks/use-tool-processor"; import { useFileStore } from "@/stores/file-store"; -const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif", "heic", "heif"] as const; -const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic", "heif"]; +const OUTPUT_FORMATS = [ + "jpg", + "png", + "webp", + "avif", + "tiff", + "gif", + "heic", + "heif", + "jxl", + "bmp", + "ico", + "jp2", + "qoi", +] as const; +const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic", "heif", "jxl", "jp2"]; + +const FORMAT_LABELS: Record = { + jpg: "JPG", + png: "PNG", + webp: "WebP", + avif: "AVIF", + tiff: "TIFF", + gif: "GIF", + heic: "HEIC", + heif: "HEIF", + jxl: "JXL", + bmp: "BMP", + ico: "ICO", + jp2: "JP2", + qoi: "QOI", +}; export interface ConvertControlsProps { settings?: Record; @@ -54,7 +84,7 @@ export function ConvertControls({ settings: initialSettings, onChange }: Convert > {OUTPUT_FORMATS.map((f) => ( ))} diff --git a/apps/web/src/components/tools/erase-object-settings.tsx b/apps/web/src/components/tools/erase-object-settings.tsx index ab05c0c7..de69e740 100644 --- a/apps/web/src/components/tools/erase-object-settings.tsx +++ b/apps/web/src/components/tools/erase-object-settings.tsx @@ -6,8 +6,18 @@ import { generateId } from "@/lib/utils"; import { useFileStore } from "@/stores/file-store"; import type { EraserCanvasRef } from "./eraser-canvas"; -const OUTPUT_FORMATS = ["png", "jpg", "webp", "avif", "tiff", "gif", "heic", "heif"] as const; -const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic", "heif"]; +const OUTPUT_FORMATS = [ + "png", + "jpg", + "webp", + "avif", + "tiff", + "gif", + "heic", + "heif", + "jxl", +] as const; +const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic", "heif", "jxl"]; interface EraseObjectSettingsProps { eraserRef: React.RefObject; 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 5edf3d55..39d38159 100644 --- a/apps/web/src/components/tools/image-to-base64-settings.tsx +++ b/apps/web/src/components/tools/image-to-base64-settings.tsx @@ -10,6 +10,7 @@ const OUTPUT_FORMATS = [ { value: "png", label: "PNG" }, { value: "webp", label: "WebP" }, { value: "avif", label: "AVIF" }, + { value: "jxl", label: "JXL" }, ] as const; export function ImageToBase64Settings() { @@ -69,7 +70,11 @@ export function ImageToBase64Settings() { }; const hasFiles = files.length > 0; - const showQuality = outputFormat === "jpeg" || outputFormat === "webp" || outputFormat === "avif"; + const showQuality = + outputFormat === "jpeg" || + outputFormat === "webp" || + outputFormat === "avif" || + outputFormat === "jxl"; return (
diff --git a/apps/web/src/components/tools/noise-removal-settings.tsx b/apps/web/src/components/tools/noise-removal-settings.tsx index 00d2c716..ef3fee11 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", "avif"]); +const LOSSY_FORMATS = new Set(["jpeg", "webp", "avif", "jxl"]); export interface NoiseRemovalControlsProps { settings?: Record; @@ -28,9 +28,9 @@ 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" | "avif">( - "original", - ); + const [outputFormat, setOutputFormat] = useState< + "original" | "png" | "jpeg" | "webp" | "avif" | "jxl" + >("original"); const [quality, setQuality] = useState(90); // One-time init from pipeline settings @@ -44,7 +44,9 @@ 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" | "avif"); + setOutputFormat( + initialSettings.format as "original" | "png" | "jpeg" | "webp" | "avif" | "jxl", + ); if (initialSettings.quality != null) setQuality(Number(initialSettings.quality)); }, [initialSettings]); @@ -164,8 +166,8 @@ export function NoiseRemovalControls({ {/* Output format */}

Output Format

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